@seeed-studio/solution-cli 0.3.1 → 0.4.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.
Files changed (33) hide show
  1. package/README.md +157 -104
  2. package/bin/cli.js +410 -58
  3. package/bin/runner.js +1 -1
  4. package/catalog/client-config.json +2 -2
  5. package/catalog/knowledge-core.json +58452 -488
  6. package/docs/cli-guide.md +173 -200
  7. package/lib/agent.js +74 -15
  8. package/lib/catalog-dto.js +14 -1
  9. package/lib/detector.js +3 -3
  10. package/lib/doctor.js +19 -1
  11. package/lib/downloader.js +24 -0
  12. package/lib/gotchas.js +126 -0
  13. package/lib/installer.js +113 -19
  14. package/lib/integration.js +265 -0
  15. package/lib/knowledge.js +838 -100
  16. package/lib/ledger.js +1 -1
  17. package/package.json +3 -6
  18. package/skills/{sensecraft → solution-cli}/SKILL.md +125 -30
  19. package/skills/{sensecraft → solution-cli}/references/consult-and-select.md +61 -13
  20. package/skills/solution-cli/references/deploy.md +112 -0
  21. package/skills/solution-cli/references/device-management.md +298 -0
  22. package/skills/{sensecraft → solution-cli}/references/diagnose.md +15 -0
  23. package/skills/solution-cli/references/edge-gotchas.md +205 -0
  24. package/skills/solution-cli/references/generic-rk3588.md +106 -0
  25. package/skills/{sensecraft → solution-cli}/references/hardware-bom.md +9 -3
  26. package/skills/solution-cli/references/sales-feasibility.md +65 -0
  27. package/skills/{sensecraft → solution-cli}/references/secondary-development.md +10 -7
  28. package/skills/sensecraft/references/deploy.md +0 -300
  29. package/skills/sensecraft/references/device-management.md +0 -126
  30. /package/skills/{sensecraft → solution-cli}/references/app-only.md +0 -0
  31. /package/skills/{sensecraft → solution-cli}/references/error-catalog.md +0 -0
  32. /package/skills/{sensecraft → solution-cli}/references/offline.md +0 -0
  33. /package/skills/{sensecraft → solution-cli}/references/verify.md +0 -0
package/lib/knowledge.js CHANGED
@@ -5,6 +5,7 @@ import https from 'node:https';
5
5
  import http from 'node:http';
6
6
  import { createCatalogDto } from './catalog-dto.js';
7
7
  import { assertCompatibility, COMPATIBILITY_STATUS } from './compatibility.js';
8
+ import { getIntegrationContract } from './integration.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
@@ -122,6 +123,594 @@ export function loadClientConfig() {
122
123
  return null;
123
124
  }
124
125
 
126
+ const SUPPORTED_LOCALES = new Set(['en', 'zh', 'ja']);
127
+ const HUB_TYPES = new Set(['scenario', 'tech', 'reference_design']);
128
+ const HUB_SECTIONS = new Set(['overview', 'links', 'description', 'architecture', 'products', 'deployment', 'playbook', 'integration']);
129
+
130
+ function levenshteinDistance(s1, s2) {
131
+ const m = s1.length;
132
+ const n = s2.length;
133
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
134
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
135
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
136
+ for (let i = 1; i <= m; i++) {
137
+ for (let j = 1; j <= n; j++) {
138
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
139
+ dp[i][j] = Math.min(
140
+ dp[i - 1][j] + 1,
141
+ dp[i][j - 1] + 1,
142
+ dp[i - 1][j - 1] + cost
143
+ );
144
+ }
145
+ }
146
+ return dp[m][n];
147
+ }
148
+
149
+ export function findClosestSection(received, allowed = Array.from(HUB_SECTIONS)) {
150
+ const norm = String(received || '').toLowerCase().trim();
151
+ if (!norm) return null;
152
+
153
+ // 1. Prefix match (e.g. "arch" -> "architecture", "integrations" -> "integration")
154
+ const prefixMatch = allowed.find(s => s.startsWith(norm) || norm.startsWith(s));
155
+ if (prefixMatch) return prefixMatch;
156
+
157
+ // 2. Substring match
158
+ const substringMatch = allowed.find(s => s.includes(norm) || norm.includes(s));
159
+ if (substringMatch) return substringMatch;
160
+
161
+ // 3. Levenshtein edit distance
162
+ let best = null;
163
+ let minDistance = Infinity;
164
+ for (const s of allowed) {
165
+ const dist = levenshteinDistance(norm, s);
166
+ if (dist < minDistance) {
167
+ minDistance = dist;
168
+ best = s;
169
+ }
170
+ }
171
+ if (minDistance <= 3) {
172
+ return best;
173
+ }
174
+ return null;
175
+ }
176
+
177
+ export class HubSectionError extends Error {
178
+ constructor(section, allowed = Array.from(HUB_SECTIONS)) {
179
+ const suggestion = findClosestSection(section, allowed);
180
+ const msg = `Unsupported hub section "${section}". Supported: ${allowed.join(', ')}.${suggestion ? ` Did you mean "${suggestion}"?` : ''}`;
181
+ super(msg);
182
+ this.name = 'HubSectionError';
183
+ this.section = section;
184
+ this.suggestion = suggestion;
185
+ this.allowed_sections = allowed;
186
+ }
187
+ }
188
+
189
+ export function computeSectionCapabilities(record, rowType, core = null) {
190
+ if (!core) core = loadKnowledgeCore();
191
+ const available_sections = ['overview', 'links'];
192
+ const section_status = {
193
+ overview: 'documented',
194
+ links: 'documented',
195
+ };
196
+
197
+ const selected = selectedContent(record, 'zh');
198
+ const content = selected.value;
199
+ const deploy = record?.deployable_content || { deployable: false, solution_id: null };
200
+
201
+ const hasDescDoc = deploy.description_documents && Object.keys(deploy.description_documents).length > 0;
202
+ const hasDescBody = Boolean(content?.description_body || content?.description || record?.summary);
203
+ if (hasDescDoc || hasDescBody) {
204
+ available_sections.push('description');
205
+ section_status.description = 'documented';
206
+ }
207
+
208
+ const hasArchOverlay = deploy.architecture_overlay && Object.keys(deploy.architecture_overlay).length > 0;
209
+ const hasArchModules = Boolean(content?.architecture?.length);
210
+ const hasGallery = Boolean(deploy.architecture_and_gallery?.length);
211
+ if (hasArchOverlay || hasArchModules || hasGallery) {
212
+ available_sections.push('architecture');
213
+ section_status.architecture = 'documented';
214
+ }
215
+
216
+ const hasProducts = (deploy.products && Object.keys(deploy.products).length > 0) || Boolean(content?.products?.length);
217
+ if (hasProducts) {
218
+ available_sections.push('products');
219
+ section_status.products = 'documented';
220
+ }
221
+
222
+ const hasGuides = deploy.deployment_guides && Object.keys(deploy.deployment_guides).length > 0;
223
+ const hasPresets = Boolean(deploy.presets?.length);
224
+ const hasGuideDocs = deploy.deployment_guide_documents && Object.keys(deploy.deployment_guide_documents).length > 0;
225
+ if (hasGuides || hasPresets || hasGuideDocs) {
226
+ available_sections.push('deployment');
227
+ section_status.deployment = 'documented';
228
+ }
229
+
230
+ let pbStatus = null;
231
+ if (record?.diagnostic_playbook || core.diagnostic_playbooks?.[record?.id]) {
232
+ pbStatus = 'documented';
233
+ } else if (rowType === 'reference_design') {
234
+ for (const scId of (record?.scenario_ids || [])) {
235
+ const sc = (core.scenarios || []).find((s) => s.id === scId);
236
+ if (sc?.diagnostic_playbook) {
237
+ pbStatus = 'inherited';
238
+ break;
239
+ }
240
+ }
241
+ } else if (rowType === 'scenario') {
242
+ for (const rdId of (record?.reference_designs || [])) {
243
+ const rd = (core.reference_designs || []).find((r) => r.id === rdId);
244
+ if (rd?.diagnostic_playbook) {
245
+ pbStatus = 'inherited';
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ if (!pbStatus && (rowType === 'scenario' || rowType === 'reference_design')) {
251
+ pbStatus = 'generic';
252
+ }
253
+ if (pbStatus) {
254
+ available_sections.push('playbook');
255
+ section_status.playbook = pbStatus;
256
+ }
257
+
258
+ const isCustomContract = ['recamera-gym-tracker', 'vision-ai-industrial-camera', 'smart-waste-sorting'].includes(deploy.solution_id || record?.id);
259
+ const hasIntegration = Boolean(deploy.deployable || deploy.solution_id || rowType === 'reference_design' || isCustomContract);
260
+ if (hasIntegration) {
261
+ available_sections.push('integration');
262
+ section_status.integration = isCustomContract ? 'documented' : 'inferred';
263
+ }
264
+
265
+ return { available_sections, section_status };
266
+ }
267
+
268
+ function normalizeLocale(locale = 'zh') {
269
+ const value = locale === 'zh-hans' ? 'zh' : String(locale || '').toLowerCase();
270
+ if (!SUPPORTED_LOCALES.has(value)) throw new Error(`Unsupported locale "${locale}". Use en, zh, or ja.`);
271
+ return value;
272
+ }
273
+
274
+ function visibilityAllows(record, locale, type) {
275
+ if (record?.published === false) return false;
276
+ if (type === 'reference_design') return record?.visibility !== 'staging' && record?.visibility !== 'draft';
277
+ const value = record?.[`visibility_${locale === 'zh' ? 'zh_hans' : locale}`] ?? record?.visibility;
278
+ return value !== 'staging' && value !== 'draft';
279
+ }
280
+
281
+ function recordType(record, fallback) {
282
+ if (fallback === 'reference_design') return fallback;
283
+ return record?.page_type === 'TECH' ? 'tech' : 'scenario';
284
+ }
285
+
286
+ function localizedValue(value, locale) {
287
+ if (!value || typeof value !== 'object') return value || '';
288
+ return value[locale] || value.zh || value.en || value.ja || '';
289
+ }
290
+
291
+ function localizedLanding(record, locale) {
292
+ return record?.landing_urls?.[locale] || record?.landing_url || null;
293
+ }
294
+
295
+ function selectedContent(record, locale) {
296
+ const requested = record.locale_content?.[locale];
297
+ if (requested) return { value: requested, locale };
298
+ for (const fallback of ['en', 'zh', 'ja']) {
299
+ if (record.locale_content?.[fallback]) return { value: record.locale_content[fallback], locale: fallback };
300
+ }
301
+ return { value: null, locale: null };
302
+ }
303
+
304
+ function selectedSummary(record, content, requestedLocale, selectedLocale) {
305
+ if (typeof content?.summary === 'string' && content.summary.trim()) {
306
+ return { value: content.summary, locale: selectedLocale };
307
+ }
308
+ if (record?.summary && typeof record.summary === 'object' && !Array.isArray(record.summary)) {
309
+ for (const locale of [requestedLocale, 'en', 'zh', 'ja']) {
310
+ if (typeof record.summary[locale] === 'string' && record.summary[locale].trim()) {
311
+ return { value: record.summary[locale], locale };
312
+ }
313
+ }
314
+ return { value: null, locale: null };
315
+ }
316
+ if (typeof record?.summary === 'string' && record.summary.trim()) {
317
+ return { value: record.summary, locale: null };
318
+ }
319
+ return { value: null, locale: null };
320
+ }
321
+
322
+ function compactRecord(record, type, locale) {
323
+ const resolvedType = recordType(record, type);
324
+ const visible = visibilityAllows(record, locale, type);
325
+ return {
326
+ id: record.id,
327
+ type: resolvedType,
328
+ title: localizedValue(record.title, locale) || record.name || record.id,
329
+ landing_url: visible ? localizedLanding(record, locale) : null,
330
+ scenario_ids: record.scenario_ids || [],
331
+ reference_designs: record.reference_designs || [],
332
+ technology_ids: record.technology_ids || [],
333
+ solution_id: record.deployable_content?.solution_id || null,
334
+ deployable: record.deployable_content?.deployable === true,
335
+ };
336
+ }
337
+
338
+ function compactSearchRecord(record) {
339
+ const { diagnostic_playbook: pb, ...compact } = record;
340
+ const hasPlaybook = Boolean(pb && (pb.questions?.length || pb.core_dimensions?.length));
341
+ const core = loadKnowledgeCore();
342
+ const bundled = (record.type === 'reference_design')
343
+ ? (core.reference_designs || []).find((r) => r.id === record.id)
344
+ : (core.scenarios || []).find((s) => s.id === record.id);
345
+ const targetRecord = bundled || record;
346
+ const caps = computeSectionCapabilities(targetRecord, record.type, core);
347
+
348
+ return {
349
+ ...compact,
350
+ summary: compact.summary || compact.subtitle?.zh || '',
351
+ available_sections: caps.available_sections,
352
+ section_status: caps.section_status,
353
+ ...(hasPlaybook ? {
354
+ has_playbook: true,
355
+ playbook_dimensions: pb.core_dimensions || [],
356
+ } : {}),
357
+ };
358
+ }
359
+
360
+ function localizedSearch(record, locale) {
361
+ const selected = selectedContent(record, locale);
362
+ const contentTitle = typeof selected.value?.title === 'string' && selected.value.title.trim() ? selected.value.title : '';
363
+ let titleLocale = null;
364
+ let title = '';
365
+ if (contentTitle && selected.locale === locale) {
366
+ titleLocale = selected.locale;
367
+ title = contentTitle;
368
+ } else {
369
+ titleLocale = [locale, 'en', 'zh', 'ja'].find((key) => typeof record?.title?.[key] === 'string' && record.title[key].trim()) || null;
370
+ title = titleLocale ? record.title[titleLocale] : contentTitle;
371
+ if (!titleLocale && contentTitle) titleLocale = selected.locale;
372
+ }
373
+ const summary = selectedSummary(record, selected.value, locale, selected.locale);
374
+ const contentLocale = summary.value ? summary.locale : titleLocale;
375
+ return {
376
+ title,
377
+ title_locale: titleLocale,
378
+ summary: summary.value || '',
379
+ content_locale: contentLocale,
380
+ fallback: contentLocale !== null && contentLocale !== locale,
381
+ };
382
+ }
383
+
384
+ function onlineLocalizedText(record, locale, fieldsByLocale) {
385
+ const fallbackLocales = locale === 'ja'
386
+ ? ['en', 'zh']
387
+ : locale === 'en'
388
+ ? ['zh', 'ja']
389
+ : ['en', 'ja'];
390
+ for (const candidateLocale of [locale, ...fallbackLocales]) {
391
+ for (const field of fieldsByLocale[candidateLocale] || []) {
392
+ const value = record?.[field];
393
+ if (typeof value === 'string' && value.trim()) return { value, locale: candidateLocale };
394
+ }
395
+ }
396
+ return { value: '', locale: null };
397
+ }
398
+
399
+ function extractArchitecturePreview(doc, bundled, locale, type) {
400
+ const locKey = locale === 'zh' ? 'zh_hans' : locale;
401
+ if (type === 'reference_design') {
402
+ const candidateIntro = doc?.[`architecture_intro_md_${locKey}`] ||
403
+ doc?.architecture_intro_md_zh_hans ||
404
+ doc?.architecture_intro_md_en ||
405
+ doc?.architecture_intro_md_ja;
406
+ if (typeof candidateIntro === 'string') {
407
+ const match = candidateIntro.match(/!\[(.*?)\]\(([^\)]+)\)/);
408
+ if (match) {
409
+ return {
410
+ image_url: match[2],
411
+ summary: match[1] || doc?.[`architecture_lead_${locKey}`] || '',
412
+ };
413
+ }
414
+ }
415
+ const overlay = bundled?.deployable_content?.architecture_overlay;
416
+ if (overlay) {
417
+ const o = overlay[locale] || overlay.zh || overlay.en || overlay.ja;
418
+ if (typeof o?.intro === 'string') {
419
+ const match = o.intro.match(/!\[(.*?)\]\(([^\)]+)\)/);
420
+ if (match) {
421
+ return {
422
+ image_url: match[2],
423
+ summary: match[1] || '',
424
+ };
425
+ }
426
+ }
427
+ }
428
+ } else if (type === 'scenario') {
429
+ const pageDataCandidate = doc?.[`page_data_${locKey}`] || doc?.page_data_zh_hans || doc?.page_data_en;
430
+ if (pageDataCandidate) {
431
+ try {
432
+ const pd = typeof pageDataCandidate === 'string' ? JSON.parse(pageDataCandidate) : pageDataCandidate;
433
+ const tabs = pd?.deployment?.tabs || [];
434
+ for (const tab of tabs) {
435
+ if (typeof tab.card_image === 'string' && tab.card_image.trim()) {
436
+ return {
437
+ image_url: tab.card_image.trim(),
438
+ summary: tab.label || tab.title || tab.id || '',
439
+ };
440
+ }
441
+ }
442
+ } catch (e) {}
443
+ }
444
+ const locContent = bundled?.locale_content?.[locale] || bundled?.locale_content?.zh || bundled?.locale_content?.en;
445
+ const archTabs = locContent?.architecture || [];
446
+ for (const tab of archTabs) {
447
+ if (typeof tab.card_image === 'string' && tab.card_image.trim()) {
448
+ return {
449
+ image_url: tab.card_image.trim(),
450
+ summary: tab.label || tab.id || '',
451
+ };
452
+ }
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+
458
+ function bundledSearchProjection(record, type, locale, source = 'bundled_manifest') {
459
+ const text = localizedSearch(record, locale);
460
+ const archPreview = extractArchitecturePreview(null, record, locale, type);
461
+ return {
462
+ type,
463
+ id: record.id,
464
+ title: text.title,
465
+ title_locale: text.title_locale,
466
+ summary: text.summary,
467
+ content_locale: text.content_locale,
468
+ fallback: text.fallback,
469
+ ...(archPreview ? { architecture_preview: archPreview } : {}),
470
+ landing_url: appendUtm(localizedLanding(record, locale), {
471
+ source: 'sensecraft_agent', medium: 'solution_consult', campaign: record.id, content: 'solution_landing',
472
+ }),
473
+ scenario_ids: record.scenario_ids || [],
474
+ technology_ids: record.technology_ids || [],
475
+ reference_designs: record.reference_designs || [],
476
+ details_available: true,
477
+ next_action: `Use hub get ${record.id} --type ${type} for verified section content.`,
478
+ source,
479
+ };
480
+ }
481
+
482
+ function allHubRecords(locale, { includeHidden = false } = {}) {
483
+ const core = loadKnowledgeCore();
484
+ const scenarios = (core.scenarios || []).filter(r => includeHidden || visibilityAllows(r, locale, 'scenario'))
485
+ .map(r => ({ record: r, type: recordType(r, 'scenario') }));
486
+ const refs = (core.reference_designs || []).filter(r => includeHidden || visibilityAllows(r, locale, 'reference_design'))
487
+ .map(r => ({ record: r, type: 'reference_design' }));
488
+ return [...scenarios, ...refs].sort((a, b) => `${a.type}:${a.record.id}`.localeCompare(`${b.type}:${b.record.id}`));
489
+ }
490
+
491
+ /** List the complete bundled page snapshot, with compact rows only. */
492
+ export function listHub({ type = null, locale = 'zh', page = 1, limit = 50 } = {}) {
493
+ const loc = normalizeLocale(locale);
494
+ if (type && !HUB_TYPES.has(type)) throw new Error(`Unsupported hub type "${type}".`);
495
+ if (!Number.isSafeInteger(page) || page < 1) throw new Error('Page must be a positive integer.');
496
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('Limit must be an integer from 1 to 200.');
497
+ const records = allHubRecords(loc).filter(({ type: rowType }) => !type || rowType === type);
498
+ const start = (page - 1) * limit;
499
+ const rows = records.slice(start, start + limit).map(({ record, type: rowType }) => compactRecord(record, rowType, loc));
500
+ const total = records.length;
501
+ return createCatalogDto({
502
+ source: 'bundled_manifest',
503
+ freshness: 'UNKNOWN',
504
+ datasetVersion: loadKnowledgeCore().dataset_version,
505
+ records: rows,
506
+ locale: loc,
507
+ scope: 'bundled_catalog_snapshot',
508
+ pagination: { page, limit, total, returned: rows.length, has_more: start + rows.length < total, next_page: start + rows.length < total ? page + 1 : null },
509
+ provenance: loadKnowledgeCore().provenance || null,
510
+ warnings: ['Snapshot completeness applies to the bundled catalog only; it is not a live website inventory.'],
511
+ });
512
+ }
513
+
514
+ function populateSectionData(row, section, record, rowType, loc, selected, content, summary, deploy, core, preset) {
515
+ if (section === 'links') {
516
+ row.links = { landing: row.landing_urls, wiki: record.wiki_url || null, repo: record.repo_url || null, declared_documents: deploy.source_files || [] };
517
+ } else if (section === 'description') {
518
+ const doc = deploy.description_documents?.[loc] || deploy.description_documents?.en || null;
519
+ const body = doc?.body || content?.description_body || content?.description || summary.value || '';
520
+ const descriptionLocale = doc?.content_locale || (body === summary.value ? summary.locale : selected.locale);
521
+ row.description = {
522
+ content_locale: descriptionLocale,
523
+ fallback: descriptionLocale !== null && descriptionLocale !== loc,
524
+ source_role: doc ? 'deployment_document' : (content?.description_source_role || 'summary'),
525
+ body,
526
+ source: doc?.source || content?.source || null,
527
+ sha256: doc?.sha256 || null,
528
+ file: deploy.description_files?.[loc] || deploy.description_files?.en || null,
529
+ };
530
+ } else if (section === 'architecture') {
531
+ const overlayLocales = deploy.architecture_overlay || {};
532
+ const architecture = overlayLocales[loc]
533
+ ? { value: overlayLocales[loc], locale: loc }
534
+ : (overlayLocales.en ? { value: overlayLocales.en, locale: 'en' }
535
+ : (overlayLocales.zh ? { value: overlayLocales.zh, locale: 'zh' }
536
+ : (overlayLocales.ja ? { value: overlayLocales.ja, locale: 'ja' } : { value: null, locale: null })));
537
+ const galleryLocale = (deploy.architecture_and_gallery || []).some((g) => g.src_i18n?.[loc]) ? loc : ((deploy.architecture_and_gallery || []).some((g) => g.src_i18n?.en) ? 'en' : null);
538
+ const overlay = architecture.value;
539
+ const archPreview = extractArchitecturePreview(null, record, loc, rowType);
540
+ row.architecture = {
541
+ content_locale: architecture.locale || (content?.content_locale ? selected.locale : galleryLocale),
542
+ fallback: Boolean(architecture.locale && architecture.locale !== loc),
543
+ source_role: overlay ? 'reference_design.overlay' : (content?.architecture?.length ? 'deployment.tabs' : 'solution.gallery'),
544
+ image_url: archPreview?.image_url || null,
545
+ summary: archPreview?.summary || null,
546
+ intro: overlay?.intro || '',
547
+ modules: overlay?.tabs || content?.architecture || [],
548
+ source: overlay?.source || null,
549
+ gallery: deploy.architecture_and_gallery || [],
550
+ };
551
+ } else if (section === 'products') {
552
+ row.products = deploy.products || content?.products || {};
553
+ row.bom = content?.bom || deploy.bom || {};
554
+ row.products_content_locale = content?.products?.length ? selected.locale : null;
555
+ row.products_source_role = content?.products?.length ? 'deployment.tabs' : 'solution.device_catalog';
556
+ } else if (section === 'deployment') {
557
+ const guide = deploy.deployment_guide_documents?.[loc] || deploy.deployment_guide_documents?.en || null;
558
+ let guideBody = guide ? guide.body : null;
559
+ let filteredByPreset = null;
560
+
561
+ if (guideBody && preset) {
562
+ const normPreset = String(preset).toLowerCase().trim();
563
+ const packagePattern = /(^|\n)##\s+套餐[^\n]*\{#([a-zA-Z0-9_\-]+)\}/g;
564
+ let match;
565
+ const sections = [];
566
+ while ((match = packagePattern.exec(guideBody)) !== null) {
567
+ sections.push({
568
+ id: match[2].toLowerCase(),
569
+ index: match.index,
570
+ });
571
+ }
572
+ if (sections.length > 0) {
573
+ const targetIdx = sections.findIndex((s) => s.id === normPreset || s.id.includes(normPreset) || normPreset.includes(s.id));
574
+ if (targetIdx !== -1) {
575
+ const start = sections[targetIdx].index;
576
+ const end = targetIdx + 1 < sections.length ? sections[targetIdx + 1].index : guideBody.length;
577
+ const header = guideBody.slice(0, sections[0].index).trim();
578
+ const bodyChunk = guideBody.slice(start, end).trim();
579
+ guideBody = `${header ? header + '\n\n---\n\n' : ''}${bodyChunk}`;
580
+ filteredByPreset = normPreset;
581
+ }
582
+ }
583
+ }
584
+
585
+ row.deployment = {
586
+ deployable: deploy.deployable === true,
587
+ solution_id: deploy.solution_id || null,
588
+ presets: deploy.presets || [],
589
+ guides: deploy.deployment_guides || {},
590
+ guide: guide ? { content_locale: guide.content_locale, body: guideBody, source: guide.source, sha256: guide.sha256 } : null,
591
+ source_files: deploy.source_files || [],
592
+ ...(filteredByPreset ? { filtered_by_preset: filteredByPreset } : {}),
593
+ };
594
+ } else if (section === 'playbook') {
595
+ let pb = record.diagnostic_playbook || core.diagnostic_playbooks?.[record.id];
596
+ if (!pb && rowType === 'reference_design') {
597
+ for (const scId of (record.scenario_ids || [])) {
598
+ const sc = (core.scenarios || []).find((s) => s.id === scId);
599
+ if (sc?.diagnostic_playbook) {
600
+ pb = sc.diagnostic_playbook;
601
+ break;
602
+ }
603
+ }
604
+ }
605
+ if (!pb && rowType === 'scenario') {
606
+ for (const rdId of (record.reference_designs || [])) {
607
+ const rd = (core.reference_designs || []).find((r) => r.id === rdId);
608
+ if (rd?.diagnostic_playbook) {
609
+ pb = rd.diagnostic_playbook;
610
+ break;
611
+ }
612
+ }
613
+ }
614
+ if (pb) {
615
+ row.playbook = {
616
+ domain: pb.domain || row.title,
617
+ core_dimensions: pb.core_dimensions || [],
618
+ questions: (pb.questions || []).map((q) => ({
619
+ dimension: q.dimension,
620
+ question: q.question,
621
+ rationale: q.rationale,
622
+ })),
623
+ source_role: 'diagnostic_playbook',
624
+ };
625
+ } else {
626
+ row.playbook = {
627
+ domain: row.title,
628
+ core_dimensions: [
629
+ '物理拓扑分布 (集中 vs 分布)',
630
+ '存量资产利旧 (摄像头/传感器)',
631
+ '供电与网络环境 (市电/电池/PoE)',
632
+ '业务执行与闭环联动 (看板/控制)',
633
+ ],
634
+ questions: [
635
+ { dimension: 'topology', question: '测点是在同一建筑/车间集中分布,还是分散在多个物理区域?', rationale: '决定采用单台高算力计算盒集中拉流,还是采用分布式独立一体化终端。' },
636
+ { dimension: 'asset_reuse', question: '现场是否已有现成的 IP 摄像头 (RTSP/ONVIF) 或传感器 (RS485/Modbus) 可以利旧?', rationale: '直接复用存量硬件可显著缩减项目落地成本与施工周期。' },
637
+ { dimension: 'power_network', question: '安装点是否有 220V 市电供电?现场网络是有线网 (PoE)、Wi-Fi 还是需 4G/LoRaWAN 远距无线?', rationale: '决定供电设备规格与通信选型。' },
638
+ ],
639
+ source_role: 'generic_diagnostic_playbook',
640
+ };
641
+ }
642
+ } else if (section === 'integration') {
643
+ const guideDoc = deploy.deployment_guide_documents?.[loc] || deploy.deployment_guide_documents?.en || null;
644
+ const contract = getIntegrationContract(deploy.solution_id || record.id, {
645
+ guideText: guideDoc?.body || '',
646
+ });
647
+ row.integration = contract;
648
+ } else if (section === 'overview') {
649
+ row.links = { landing: row.landing_urls, wiki: record.wiki_url || null, repo: record.repo_url || null };
650
+ row.description = { content_locale: summary.locale, fallback: summary.locale !== null && summary.locale !== loc, source_role: 'summary', summary: summary.value || '' };
651
+ row.relationships = { scenario_ids: record.scenario_ids || [], reference_designs: record.reference_designs || [], technology_ids: record.technology_ids || [] };
652
+ row.deployable_content = { deployable: deploy.deployable === true, solution_id: deploy.solution_id || null, presets: deploy.presets || [], guides: deploy.deployment_guides || {} };
653
+ }
654
+ }
655
+
656
+ /** Get one selected graph record and progressive content section(s). */
657
+ export function getHubRecord(id, { type = null, locale = 'zh', section = null, sections = null, preset = null } = {}) {
658
+ const loc = normalizeLocale(locale);
659
+ if (!id) throw new Error('A hub record ID is required.');
660
+ if (type && !HUB_TYPES.has(type)) throw new Error(`Unsupported hub type "${type}".`);
661
+
662
+ let requestedSections = [];
663
+ if (sections) {
664
+ if (Array.isArray(sections)) {
665
+ requestedSections = sections.flatMap((s) => String(s).split(','));
666
+ } else if (typeof sections === 'string') {
667
+ requestedSections = sections.split(',');
668
+ }
669
+ } else if (section) {
670
+ requestedSections = String(section).split(',');
671
+ } else {
672
+ requestedSections = ['overview'];
673
+ }
674
+ requestedSections = requestedSections.map((s) => s.trim().toLowerCase()).filter(Boolean);
675
+ if (requestedSections.length === 0) requestedSections = ['overview'];
676
+
677
+ for (const sec of requestedSections) {
678
+ if (!HUB_SECTIONS.has(sec)) {
679
+ throw new HubSectionError(sec, Array.from(HUB_SECTIONS));
680
+ }
681
+ }
682
+
683
+ const matches = allHubRecords(loc, { includeHidden: true }).filter(({ record, type: rowType }) => record.id === id && (!type || rowType === type));
684
+ if (matches.length === 0) return null;
685
+ if (matches.length > 1 && !type) throw new Error(`ID "${id}" is ambiguous; specify --type scenario, tech, or reference_design.`);
686
+ const { record, type: rowType } = matches[0];
687
+ const row = compactRecord(record, rowType, loc);
688
+ row.visibility = record.visibility || record[`visibility_${loc === 'zh' ? 'zh_hans' : loc}`] || null;
689
+ row.landing_urls = Object.fromEntries(Object.entries(record.landing_urls || {})
690
+ .filter(([landingLocale]) => visibilityAllows(record, landingLocale, rowType)));
691
+ const selected = selectedContent(record, loc);
692
+ const content = selected.value;
693
+ const summary = selectedSummary(record, content, loc, selected.locale);
694
+ const deploy = record.deployable_content || { deployable: false, solution_id: null };
695
+ const core = loadKnowledgeCore();
696
+
697
+ const caps = computeSectionCapabilities(record, rowType, core);
698
+ row.available_sections = caps.available_sections;
699
+ row.section_status = caps.section_status;
700
+
701
+ if (requestedSections.length === 1) {
702
+ row.section = requestedSections[0];
703
+ populateSectionData(row, requestedSections[0], record, rowType, loc, selected, content, summary, deploy, core, preset);
704
+ } else {
705
+ row.sections = requestedSections;
706
+ for (const sec of requestedSections) {
707
+ populateSectionData(row, sec, record, rowType, loc, selected, content, summary, deploy, core, preset);
708
+ }
709
+ }
710
+
711
+ return row;
712
+ }
713
+
125
714
  /**
126
715
  * Searches Seeed Solutions Hub scenarios and reference designs.
127
716
  *
@@ -132,6 +721,9 @@ export function loadClientConfig() {
132
721
  * @returns {Promise<Object>} Standardized Catalog DTO
133
722
  */
134
723
  export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
724
+ const normalizedLocale = normalizeLocale(locale);
725
+ locale = normalizedLocale;
726
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('Limit must be an integer from 1 to 200.');
135
727
  const qLower = (query || '').toLowerCase().trim();
136
728
  const clientConfig = loadClientConfig();
137
729
  const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
@@ -180,25 +772,41 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
180
772
  if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
181
773
 
182
774
  const docId = doc.id || doc.slug;
183
- const docType = doc.doc_type || 'scenario';
184
- const title = locale === 'en'
185
- ? (doc.title_en || doc.title_zh_hans || docId)
186
- : (doc.title_zh_hans || doc.title_en || docId);
187
- const summary = locale === 'en'
188
- ? (doc.excerpt_en || doc.seo_description_en || doc.excerpt_zh_hans || '')
189
- : (doc.excerpt_zh_hans || doc.seo_description_zh_hans || doc.excerpt_en || '');
190
- const tags = (locale === 'en' ? doc.display_tags_en : doc.display_tags_zh_hans) || doc.display_tags || [];
775
+ const docType = doc.doc_type || recordType((core.scenarios || []).find((s) => s.id === docId), 'scenario');
776
+ const titleText = onlineLocalizedText(doc, locale, {
777
+ en: ['title_en'], zh: ['title_zh_hans'], ja: ['title_ja'],
778
+ });
779
+ const summaryText = onlineLocalizedText(doc, locale, {
780
+ en: ['excerpt_en', 'seo_description_en'],
781
+ zh: ['excerpt_zh_hans', 'seo_description_zh_hans'],
782
+ ja: ['excerpt_ja', 'seo_description_ja'],
783
+ });
784
+ const title = titleText.value || docId;
785
+ const summary = summaryText.value;
786
+ const tags = (locale === 'en' ? doc.display_tags_en : locale === 'ja' ? (doc.display_tags_ja || doc.display_tags_en) : doc.display_tags_zh_hans) || doc.display_tags || [];
787
+ const bundledScenario = (core.scenarios || []).find((s) => s.id === docId);
788
+ const resolvedType = ['scenario', 'tech', 'reference_design'].includes(docType) ? docType : recordType(bundledScenario, 'scenario');
191
789
  const pb = core.diagnostic_playbooks?.[docId] || doc.diagnostic_playbook || null;
192
- const rawLanding = doc.external_url || doc.landing_url || `https://www.seeedstudio.com/solutions/${docId}`;
790
+ const rawLanding = doc.external_url || doc.landing_url || localizedLanding(bundledScenario, locale) || null;
791
+ const archPreview = extractArchitecturePreview(doc, bundledScenario, locale, 'scenario');
193
792
 
194
793
  onlineScenarios.push({
195
- type: docType,
794
+ type: resolvedType,
196
795
  id: docId,
197
796
  title,
198
797
  summary,
199
798
  tags,
200
799
  family_ids: doc.family_ids || [],
201
800
  reference_designs: doc.reference_designs || [],
801
+ technology_ids: doc.technology_ids || bundledScenario?.technology_ids || [],
802
+ scenario_ids: doc.scenario_ids || bundledScenario?.scenario_ids || [],
803
+ title_locale: titleText.locale,
804
+ summary_locale: summaryText.locale,
805
+ content_locale: summaryText.locale || titleText.locale,
806
+ fallback: (summaryText.locale || titleText.locale) !== null && (summaryText.locale || titleText.locale) !== locale,
807
+ ...(archPreview ? { architecture_preview: archPreview } : {}),
808
+ details_available: Boolean(bundledScenario),
809
+ next_action: bundledScenario ? `Use hub get ${docId} --type ${resolvedType} for verified section content.` : `Open the production landing page for ${docId}; bundled section content is unavailable.`,
202
810
  landing_url: appendUtm(rawLanding, {
203
811
  source: 'sensecraft_agent',
204
812
  medium: 'solution_consult',
@@ -218,12 +826,16 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
218
826
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
219
827
  if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
220
828
  const docId = doc.id;
221
- const title = locale === 'en'
222
- ? (doc.name_en || doc.name_zh_hans || docId)
223
- : (doc.name_zh_hans || doc.name_en || docId);
224
- const summary = locale === 'en'
225
- ? (doc.summary_en || doc.tagline_en || doc.summary_zh_hans || '')
226
- : (doc.summary_zh_hans || doc.tagline_zh_hans || doc.summary_en || '');
829
+ const titleText = onlineLocalizedText(doc, locale, {
830
+ en: ['name_en'], zh: ['name_zh_hans'], ja: ['name_ja'],
831
+ });
832
+ const summaryText = onlineLocalizedText(doc, locale, {
833
+ en: ['summary_en', 'tagline_en'],
834
+ zh: ['summary_zh_hans', 'tagline_zh_hans'],
835
+ ja: ['summary_ja', 'tagline_ja'],
836
+ });
837
+ const title = titleText.value || docId;
838
+ const summary = summaryText.value;
227
839
 
228
840
  let pb = doc.diagnostic_playbook || core.diagnostic_playbooks?.[docId] || null;
229
841
  if (!pb) {
@@ -246,6 +858,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
246
858
 
247
859
  const bundledRd = (core.reference_designs || []).find((r) => r.id === docId);
248
860
  const repoUrl = doc.repo_url || bundledRd?.repo_url || '';
861
+ const archPreview = extractArchitecturePreview(doc, bundledRd, locale, 'reference_design');
249
862
 
250
863
  onlineRefDesigns.push({
251
864
  type: 'reference_design',
@@ -253,7 +866,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
253
866
  title,
254
867
  summary,
255
868
  presets,
256
- landing_url: appendUtm(`https://www.seeedstudio.com/solutions/reference-designs/${docId}`, {
869
+ landing_url: appendUtm(doc.landing_url?.[locale] || doc.external_url?.[locale] || bundledRd?.landing_urls?.[locale] || null, {
257
870
  source: 'sensecraft_agent',
258
871
  medium: 'solution_consult',
259
872
  campaign: docId,
@@ -265,6 +878,15 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
265
878
  campaign: docId,
266
879
  }) : '',
267
880
  repo_url: repoUrl,
881
+ title_locale: titleText.locale,
882
+ summary_locale: summaryText.locale,
883
+ content_locale: summaryText.locale || titleText.locale,
884
+ fallback: (summaryText.locale || titleText.locale) !== null && (summaryText.locale || titleText.locale) !== locale,
885
+ ...(archPreview ? { architecture_preview: archPreview } : {}),
886
+ scenario_ids: bundledRd?.scenario_ids || [],
887
+ technology_ids: bundledRd?.technology_ids || [],
888
+ details_available: Boolean(bundledRd),
889
+ next_action: bundledRd ? `Use hub get ${docId} --type reference_design for verified section content.` : `Open the production landing page for ${docId}; bundled section content is unavailable.`,
268
890
  diagnostic_playbook: pb,
269
891
  source: 'typesense',
270
892
  });
@@ -281,27 +903,11 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
281
903
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
282
904
  if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
283
905
 
284
- const haystack = [s.id, s.title?.zh, s.title?.en, s.subtitle?.zh, s.subtitle?.en, s.summary, ...(s.tags || [])].join(' ').toLowerCase();
906
+ const localizedFields = Object.values(s.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
907
+ const haystack = [s.id, s.title?.zh, s.title?.en, s.title?.ja, s.subtitle?.zh, s.subtitle?.en, s.subtitle?.ja, s.summary, ...localizedFields, ...(s.tags || [])].join(' ').toLowerCase();
285
908
  const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
286
909
  if (tokens.length === 0 || score > 0) {
287
- onlineScenarios.push({
288
- type: 'scenario',
289
- id: s.id,
290
- title: s.title?.[locale] || s.title?.zh || s.title?.en || s.id,
291
- summary: s.subtitle?.[locale] || s.subtitle?.zh || s.summary || '',
292
- tags: s.tags || [],
293
- family_ids: s.family_ids || [],
294
- reference_designs: s.reference_designs || [],
295
- landing_url: appendUtm(s.landing_url || `https://www.seeedstudio.com/solutions/${s.id}`, {
296
- source: 'sensecraft_agent',
297
- medium: 'solution_consult',
298
- campaign: s.id,
299
- content: 'scenario_landing',
300
- }),
301
- repo_url: s.repo_url || '',
302
- diagnostic_playbook: s.diagnostic_playbook || null,
303
- source: 'bundled_manifest',
304
- });
910
+ onlineScenarios.push({ ...bundledSearchProjection({ ...s, summary: s.subtitle || s.summary }, recordType(s, 'scenario'), locale), tags: s.tags || [], family_ids: s.family_ids || [], repo_url: s.repo_url || '', diagnostic_playbook: s.diagnostic_playbook || null });
305
911
  }
306
912
  }
307
913
  }
@@ -311,30 +917,11 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
311
917
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
312
918
  if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
313
919
 
314
- const haystack = [rd.id, rd.title?.zh, rd.title?.en, rd.summary?.zh, rd.summary?.en, ...(rd.presets || [])].join(' ').toLowerCase();
920
+ const localizedFields = Object.values(rd.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
921
+ const haystack = [rd.id, rd.title?.zh, rd.title?.en, rd.title?.ja, rd.summary?.zh, rd.summary?.en, rd.summary?.ja, ...localizedFields, ...(rd.presets || [])].join(' ').toLowerCase();
315
922
  const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
316
923
  if (tokens.length === 0 || score > 0) {
317
- onlineRefDesigns.push({
318
- type: 'reference_design',
319
- id: rd.id,
320
- title: rd.title?.[locale] || rd.title?.zh || rd.title?.en || rd.id,
321
- summary: rd.summary?.[locale] || rd.summary?.zh || rd.summary?.en || '',
322
- presets: rd.presets || [],
323
- landing_url: appendUtm(rd.landing_url || `https://www.seeedstudio.com/solutions/reference-designs/${rd.id}`, {
324
- source: 'sensecraft_agent',
325
- medium: 'solution_consult',
326
- campaign: rd.id,
327
- content: 'solution_landing',
328
- }),
329
- wiki_url: rd.wiki_url ? appendUtm(rd.wiki_url, {
330
- source: 'sensecraft_agent',
331
- medium: 'tech_wiki',
332
- campaign: rd.id,
333
- }) : '',
334
- repo_url: rd.repo_url || '',
335
- diagnostic_playbook: rd.diagnostic_playbook || null,
336
- source: 'bundled_manifest',
337
- });
924
+ onlineRefDesigns.push({ ...bundledSearchProjection(rd, 'reference_design', locale), presets: rd.presets || [], wiki_url: rd.wiki_url || '', repo_url: rd.repo_url || '', diagnostic_playbook: rd.diagnostic_playbook || null });
338
925
  }
339
926
  }
340
927
  }
@@ -354,8 +941,11 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
354
941
  if (mergedRecords.length > 0) {
355
942
  return createCatalogDto({
356
943
  source: (onlineScenarios.some(r => r.source === 'typesense') || onlineRefDesigns.some(r => r.source === 'typesense')) ? 'typesense' : 'bundled_manifest',
357
- freshness: 'FRESH',
358
- records: mergedRecords,
944
+ freshness: 'UNKNOWN',
945
+ datasetVersion: core.dataset_version,
946
+ scope: 'online_partial_with_bundled_fallback',
947
+ warnings: ['Online search results are partial and do not replace the bundled catalog; use hub get for verified section content.'],
948
+ records: mergedRecords.map(compactSearchRecord),
359
949
  });
360
950
  }
361
951
  } catch {
@@ -375,32 +965,26 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
375
965
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
376
966
  if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
377
967
 
968
+ const localizedFields = Object.values(s.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
378
969
  const haystack = [
379
970
  s.id,
380
971
  s.title?.zh,
381
972
  s.title?.en,
973
+ s.title?.ja,
382
974
  s.subtitle?.zh,
383
975
  s.subtitle?.en,
976
+ s.subtitle?.ja,
384
977
  s.summary,
978
+ ...localizedFields,
385
979
  ...(s.tags || []),
386
980
  ].join(' ').toLowerCase();
387
981
 
388
982
  const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
389
983
  if (tokens.length === 0 || score > 0) {
390
984
  matchedScenarios.push({
391
- type: 'scenario',
392
- id: s.id,
393
- title: s.title?.[locale] || s.title?.zh || s.title?.en || s.id,
394
- summary: s.subtitle?.[locale] || s.subtitle?.zh || s.summary || '',
985
+ ...bundledSearchProjection({ ...s, summary: s.subtitle || s.summary }, recordType(s, 'scenario'), locale),
395
986
  tags: s.tags || [],
396
987
  family_ids: s.family_ids || [],
397
- reference_designs: s.reference_designs || [],
398
- landing_url: appendUtm(s.landing_url || `https://www.seeedstudio.com/solutions/${s.id}`, {
399
- source: 'sensecraft_agent',
400
- medium: 'solution_consult',
401
- campaign: s.id,
402
- content: 'scenario_landing',
403
- }),
404
988
  repo_url: s.repo_url || '',
405
989
  diagnostic_playbook: s.diagnostic_playbook || null,
406
990
  score,
@@ -413,29 +997,24 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
413
997
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
414
998
  if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
415
999
 
1000
+ const localizedFields = Object.values(rd.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
416
1001
  const haystack = [
417
1002
  rd.id,
418
1003
  rd.title?.zh,
419
1004
  rd.title?.en,
1005
+ rd.title?.ja,
420
1006
  rd.summary?.zh,
421
1007
  rd.summary?.en,
1008
+ rd.summary?.ja,
1009
+ ...localizedFields,
422
1010
  ...(rd.presets || []),
423
1011
  ].join(' ').toLowerCase();
424
1012
 
425
1013
  const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
426
1014
  if (tokens.length === 0 || score > 0) {
427
1015
  matchedRefDesigns.push({
428
- type: 'reference_design',
429
- id: rd.id,
430
- title: rd.title?.[locale] || rd.title?.zh || rd.title?.en || rd.id,
431
- summary: rd.summary?.[locale] || rd.summary?.zh || rd.summary?.en || '',
1016
+ ...bundledSearchProjection(rd, 'reference_design', locale),
432
1017
  presets: rd.presets || [],
433
- landing_url: appendUtm(rd.landing_url || `https://www.seeedstudio.com/solutions/reference-designs/${rd.id}`, {
434
- source: 'sensecraft_agent',
435
- medium: 'solution_consult',
436
- campaign: rd.id,
437
- content: 'solution_landing',
438
- }),
439
1018
  wiki_url: rd.wiki_url ? appendUtm(rd.wiki_url, {
440
1019
  source: 'sensecraft_agent',
441
1020
  medium: 'tech_wiki',
@@ -465,11 +1044,15 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
465
1044
 
466
1045
  return createCatalogDto({
467
1046
  source: 'bundled_manifest',
468
- freshness: 'FRESH',
469
- records: combined,
470
- warnings: (keySolution || keyRefDesign)
471
- ? ['Online Typesense request timed out or returned no hits. Displaying verified local catalog.']
472
- : ['No Typesense API key configured or baked. Displaying verified local catalog.'],
1047
+ freshness: 'UNKNOWN',
1048
+ datasetVersion: core.dataset_version,
1049
+ scope: 'bundled_catalog_snapshot',
1050
+ records: combined.map(compactSearchRecord),
1051
+ warnings: process.env.SENSECRAFT_OFFLINE_ONLY
1052
+ ? ['Offline-only mode: displaying the verified bundled catalog; no online request was attempted.']
1053
+ : ((keySolution || keyRefDesign)
1054
+ ? ['Online Typesense request timed out or returned no hits. Displaying verified local catalog.']
1055
+ : ['No Typesense API key configured or baked. Displaying verified local catalog.']),
473
1056
  });
474
1057
  }
475
1058
 
@@ -524,11 +1107,116 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
524
1107
  *
525
1108
  * @param {string} familyId
526
1109
  * @param {Object} [options]
1110
+ /**
1111
+ * Bazaar bulk-add-cart supports simple numeric SKUs.
1112
+ *
1113
+ * @param {string|null|undefined} sku
1114
+ * @returns {boolean}
1115
+ */
1116
+ export function isCartableSku(sku) {
1117
+ if (!sku) return false;
1118
+ return /^\d+$/.test(String(sku).trim());
1119
+ }
1120
+
1121
+ /**
1122
+ * Storefront domain per site locale (ja -> jp.seeedstudio.com, else www.seeedstudio.com).
1123
+ *
1124
+ * @param {string} [locale='en']
1125
+ * @returns {string}
1126
+ */
1127
+ export function storeBaseUrlForLocale(locale) {
1128
+ return locale === 'ja'
1129
+ ? 'https://jp.seeedstudio.com'
1130
+ : 'https://www.seeedstudio.com';
1131
+ }
1132
+
1133
+ /**
1134
+ * Chinese locale does not check out on global store.
1135
+ *
1136
+ * @param {string} [locale]
1137
+ * @returns {boolean}
1138
+ */
1139
+ export function localeSupportsCart(locale) {
1140
+ return locale !== 'zh-hans' && locale !== 'zh';
1141
+ }
1142
+
1143
+ /**
1144
+ * Builds Seeed Bazaar bulk-add-cart deep link with UTM attribution.
1145
+ *
1146
+ * @param {Array<{sku: string, qty: number}>} items
1147
+ * @param {Object} [options]
1148
+ * @param {string} [options.solutionSlug='solution']
1149
+ * @param {string} [options.locale='en']
1150
+ * @param {string} [options.requestId]
1151
+ * @returns {string|null}
1152
+ */
1153
+ export function buildBulkAddCartUrl(items, { solutionSlug = 'solution', locale = 'en', requestId = null } = {}) {
1154
+ const validItems = (items || [])
1155
+ .filter((it) => it && isCartableSku(it.sku) && Number.isInteger(it.qty) && it.qty > 0)
1156
+ .map((it) => `${it.sku}:${it.qty}`);
1157
+
1158
+ if (validItems.length === 0) return null;
1159
+ if (!localeSupportsCart(locale)) return null;
1160
+
1161
+ const reqId = requestId || `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1162
+ const medium = encodeURIComponent(`Seeed::Solution::${solutionSlug}::sample-purchase`);
1163
+ const baseUrl = storeBaseUrlForLocale(locale);
1164
+ const rawUrl = `${baseUrl}/bulk-add-cart?items=${validItems.join(',')}&requestId=${encodeURIComponent(reqId)}&c_event_medium=${medium}`;
1165
+
1166
+ return appendUtm(rawUrl, {
1167
+ source: 'sensecraft_agent',
1168
+ medium: 'solution_bom',
1169
+ campaign: solutionSlug,
1170
+ content: 'sample_purchase',
1171
+ });
1172
+ }
1173
+
1174
+ /**
1175
+ * Evaluates whether an accessory with applies_when condition matches the selected host SKU.
1176
+ *
1177
+ * @param {Object} acc - Accessory definition
1178
+ * @param {string} hostSku - Selected host SKU
1179
+ * @param {Object} family - Product family object
1180
+ * @returns {boolean}
1181
+ */
1182
+ export function isAccessoryApplicable(acc, hostSku, family) {
1183
+ if (!acc || !acc.applies_when) {
1184
+ return true;
1185
+ }
1186
+ const { attr, value } = acc.applies_when;
1187
+ if (!attr) return true;
1188
+
1189
+ const skuObj = (family?.skus || []).find((s) => s.sku === hostSku);
1190
+ const actualValue = skuObj?.attrs?.[attr];
1191
+
1192
+ if (actualValue === undefined || actualValue === null) {
1193
+ const fallbackValue = family?.[attr] ?? family?.physical_spec?.[attr];
1194
+ if (fallbackValue !== undefined && fallbackValue !== null) {
1195
+ return Array.isArray(value) ? value.includes(fallbackValue) : value === fallbackValue;
1196
+ }
1197
+ if (skuObj && skuObj.attrs) {
1198
+ return false;
1199
+ }
1200
+ return true;
1201
+ }
1202
+
1203
+ if (Array.isArray(value)) {
1204
+ return value.includes(actualValue);
1205
+ }
1206
+ return value === actualValue;
1207
+ }
1208
+
1209
+ /**
1210
+ * Queries accessories for a given product family and evaluates compatibility.
1211
+ *
1212
+ * @param {string} familyId - Product family ID
1213
+ * @param {Object} [options]
527
1214
  * @param {string} [options.sku] - Specific host SKU
528
1215
  * @param {string} [options.locale='zh']
1216
+ * @param {boolean} [options.compact=false] - Return token-efficient compact payload
529
1217
  * @returns {Object} Catalog DTO with structured, verified accessories
530
1218
  */
531
- export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
1219
+ export function queryAccessories(familyId, { sku = null, locale = 'zh', compact = false, category = null } = {}) {
532
1220
  const core = loadKnowledgeCore();
533
1221
  const family = core.product_families?.[familyId];
534
1222
 
@@ -560,11 +1248,13 @@ export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
560
1248
  qty: acc.qty || 1,
561
1249
  reason: acc.reason || 'Required for standard operation',
562
1250
  applies_when: acc.applies_when || null,
563
- compatibility: {
564
- is_compatible: comp.isCompatible,
565
- status: comp.status,
566
- details: comp.dimensions,
567
- },
1251
+ compatibility: compact
1252
+ ? { is_compatible: comp.isCompatible, status: comp.status }
1253
+ : {
1254
+ is_compatible: comp.isCompatible,
1255
+ status: comp.status,
1256
+ details: comp.dimensions,
1257
+ },
568
1258
  };
569
1259
  });
570
1260
 
@@ -583,25 +1273,73 @@ export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
583
1273
  content: `optional_${acc.id}`,
584
1274
  }),
585
1275
  type: acc.type || 'peripheral',
586
- compatibility: {
587
- is_compatible: comp.isCompatible,
588
- status: comp.status,
589
- details: comp.dimensions,
590
- },
1276
+ compatibility: compact
1277
+ ? { is_compatible: comp.isCompatible, status: comp.status }
1278
+ : {
1279
+ is_compatible: comp.isCompatible,
1280
+ status: comp.status,
1281
+ details: comp.dimensions,
1282
+ },
591
1283
  };
592
1284
  });
593
1285
 
1286
+ let filteredRequired = required;
1287
+ let filteredOptional = optional;
1288
+
1289
+ if (category) {
1290
+ const catTokens = String(category).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
1291
+ if (catTokens.length > 0) {
1292
+ filteredRequired = required.filter((acc) => {
1293
+ if (catTokens.includes('required')) return true;
1294
+ if (catTokens.includes('optional')) return false;
1295
+ const txt = [acc.id, acc.name, acc.sku, acc.category, acc.reason].join(' ').toLowerCase();
1296
+ return catTokens.some((tok) => txt.includes(tok));
1297
+ });
1298
+ filteredOptional = optional.filter((acc) => {
1299
+ if (catTokens.includes('optional')) return true;
1300
+ if (catTokens.includes('required')) return false;
1301
+ const txt = [acc.id, acc.name, acc.sku, acc.category, acc.type].join(' ').toLowerCase();
1302
+ return catTokens.some((tok) => txt.includes(tok));
1303
+ });
1304
+ }
1305
+ }
1306
+
1307
+ const hostSku = sku || family.default_sku;
1308
+ const cartItems = [];
1309
+ if (isCartableSku(hostSku)) {
1310
+ cartItems.push({ sku: hostSku, qty: 1 });
1311
+ }
1312
+ for (const req of filteredRequired) {
1313
+ const isApplicable = isAccessoryApplicable(req, hostSku, family);
1314
+ req.applicable = isApplicable;
1315
+ if (isApplicable && isCartableSku(req.sku)) {
1316
+ cartItems.push({ sku: req.sku, qty: req.qty || 1 });
1317
+ }
1318
+ }
1319
+
1320
+ const bundleCartUrl = buildBulkAddCartUrl(cartItems, {
1321
+ solutionSlug: family.id,
1322
+ locale,
1323
+ });
1324
+
594
1325
  return createCatalogDto({
595
1326
  source: 'bundled_manifest',
596
1327
  freshness: 'FRESH',
597
1328
  records: [{
598
1329
  family_id: family.id,
599
1330
  host_title: family.title?.[locale] || family.title?.zh || family.title?.en,
600
- host_sku: sku || family.default_sku,
1331
+ host_sku: hostSku,
601
1332
  physical_spec: hostPhysicalSpec,
1333
+ bundle_cart_url: bundleCartUrl,
1334
+ ...(bundleCartUrl ? {} : {
1335
+ domestic_channel_note: locale === 'zh'
1336
+ ? '国内采购建议通过 Seeed 官方直营店铺或授权分销商采购(非国际站美金结算)'
1337
+ : null,
1338
+ }),
1339
+ ...(category ? { filtered_by_category: category } : {}),
602
1340
  accessories: {
603
- required,
604
- optional,
1341
+ required: filteredRequired,
1342
+ optional: filteredOptional,
605
1343
  },
606
1344
  }],
607
1345
  });