@seeed-studio/solution-cli 0.3.2 → 0.4.1

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/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,623 @@ 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
+
714
+ /**
715
+ * Detects if a search query matches known hardware product families, models, or SKUs.
716
+ *
717
+ * @param {string[]} tokens
718
+ * @param {string} query
719
+ * @param {Object} core
720
+ * @returns {string|null} Helpful redirect note if hardware is detected
721
+ */
722
+ function detectHardwareIntent(tokens, query, core) {
723
+ if (!tokens || tokens.length === 0) return null;
724
+ const families = Object.values(core?.product_families || {});
725
+ const matchedHw = families.find((fam) => {
726
+ const fid = (fam.id || '').toLowerCase();
727
+ const zh = (fam.title?.zh || '').toLowerCase();
728
+ const en = (fam.title?.en || '').toLowerCase();
729
+ if (tokens.some((t) => fid.includes(t) || zh.includes(t) || en.includes(t))) return true;
730
+ return (fam.skus || []).some((s) => {
731
+ const sNum = String(s.sku || '').toLowerCase();
732
+ const sModel = String(s.model || '').toLowerCase();
733
+ const sName = String(s.name || '').toLowerCase();
734
+ return tokens.some((t) => sNum === t || (sModel && sModel.includes(t)) || sName.includes(t));
735
+ });
736
+ });
737
+ if (matchedHw) {
738
+ return `💡 提示: 检测到硬件设备/型号关键词 "${query}"。若需查询该硬件的物理接口、包装清单(是否带电源)或官方落地应用方案,请使用: solution-cli product search "${query}"`;
739
+ }
740
+ return null;
741
+ }
742
+
125
743
  /**
126
744
  * Searches Seeed Solutions Hub scenarios and reference designs.
127
745
  *
@@ -132,7 +750,11 @@ export function loadClientConfig() {
132
750
  * @returns {Promise<Object>} Standardized Catalog DTO
133
751
  */
134
752
  export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
753
+ const normalizedLocale = normalizeLocale(locale);
754
+ locale = normalizedLocale;
755
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('Limit must be an integer from 1 to 200.');
135
756
  const qLower = (query || '').toLowerCase().trim();
757
+ const tokens = qLower.split(/\s+/).filter(Boolean);
136
758
  const clientConfig = loadClientConfig();
137
759
  const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
138
760
  const keySolution = process.env.TYPESENSE_SEARCH_KEY_SOLUTION || process.env.TYPESENSE_SEARCH_KEY || clientConfig?.typesense?.search_key_solution;
@@ -180,25 +802,41 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
180
802
  if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
181
803
 
182
804
  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 || [];
805
+ const docType = doc.doc_type || recordType((core.scenarios || []).find((s) => s.id === docId), 'scenario');
806
+ const titleText = onlineLocalizedText(doc, locale, {
807
+ en: ['title_en'], zh: ['title_zh_hans'], ja: ['title_ja'],
808
+ });
809
+ const summaryText = onlineLocalizedText(doc, locale, {
810
+ en: ['excerpt_en', 'seo_description_en'],
811
+ zh: ['excerpt_zh_hans', 'seo_description_zh_hans'],
812
+ ja: ['excerpt_ja', 'seo_description_ja'],
813
+ });
814
+ const title = titleText.value || docId;
815
+ const summary = summaryText.value;
816
+ 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 || [];
817
+ const bundledScenario = (core.scenarios || []).find((s) => s.id === docId);
818
+ const resolvedType = ['scenario', 'tech', 'reference_design'].includes(docType) ? docType : recordType(bundledScenario, 'scenario');
191
819
  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}`;
820
+ const rawLanding = doc.external_url || doc.landing_url || localizedLanding(bundledScenario, locale) || null;
821
+ const archPreview = extractArchitecturePreview(doc, bundledScenario, locale, 'scenario');
193
822
 
194
823
  onlineScenarios.push({
195
- type: docType,
824
+ type: resolvedType,
196
825
  id: docId,
197
826
  title,
198
827
  summary,
199
828
  tags,
200
829
  family_ids: doc.family_ids || [],
201
830
  reference_designs: doc.reference_designs || [],
831
+ technology_ids: doc.technology_ids || bundledScenario?.technology_ids || [],
832
+ scenario_ids: doc.scenario_ids || bundledScenario?.scenario_ids || [],
833
+ title_locale: titleText.locale,
834
+ summary_locale: summaryText.locale,
835
+ content_locale: summaryText.locale || titleText.locale,
836
+ fallback: (summaryText.locale || titleText.locale) !== null && (summaryText.locale || titleText.locale) !== locale,
837
+ ...(archPreview ? { architecture_preview: archPreview } : {}),
838
+ details_available: Boolean(bundledScenario),
839
+ 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
840
  landing_url: appendUtm(rawLanding, {
203
841
  source: 'sensecraft_agent',
204
842
  medium: 'solution_consult',
@@ -218,12 +856,16 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
218
856
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
219
857
  if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
220
858
  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 || '');
859
+ const titleText = onlineLocalizedText(doc, locale, {
860
+ en: ['name_en'], zh: ['name_zh_hans'], ja: ['name_ja'],
861
+ });
862
+ const summaryText = onlineLocalizedText(doc, locale, {
863
+ en: ['summary_en', 'tagline_en'],
864
+ zh: ['summary_zh_hans', 'tagline_zh_hans'],
865
+ ja: ['summary_ja', 'tagline_ja'],
866
+ });
867
+ const title = titleText.value || docId;
868
+ const summary = summaryText.value;
227
869
 
228
870
  let pb = doc.diagnostic_playbook || core.diagnostic_playbooks?.[docId] || null;
229
871
  if (!pb) {
@@ -246,6 +888,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
246
888
 
247
889
  const bundledRd = (core.reference_designs || []).find((r) => r.id === docId);
248
890
  const repoUrl = doc.repo_url || bundledRd?.repo_url || '';
891
+ const archPreview = extractArchitecturePreview(doc, bundledRd, locale, 'reference_design');
249
892
 
250
893
  onlineRefDesigns.push({
251
894
  type: 'reference_design',
@@ -253,7 +896,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
253
896
  title,
254
897
  summary,
255
898
  presets,
256
- landing_url: appendUtm(`https://www.seeedstudio.com/solutions/reference-designs/${docId}`, {
899
+ landing_url: appendUtm(doc.landing_url?.[locale] || doc.external_url?.[locale] || bundledRd?.landing_urls?.[locale] || null, {
257
900
  source: 'sensecraft_agent',
258
901
  medium: 'solution_consult',
259
902
  campaign: docId,
@@ -265,6 +908,15 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
265
908
  campaign: docId,
266
909
  }) : '',
267
910
  repo_url: repoUrl,
911
+ title_locale: titleText.locale,
912
+ summary_locale: summaryText.locale,
913
+ content_locale: summaryText.locale || titleText.locale,
914
+ fallback: (summaryText.locale || titleText.locale) !== null && (summaryText.locale || titleText.locale) !== locale,
915
+ ...(archPreview ? { architecture_preview: archPreview } : {}),
916
+ scenario_ids: bundledRd?.scenario_ids || [],
917
+ technology_ids: bundledRd?.technology_ids || [],
918
+ details_available: Boolean(bundledRd),
919
+ 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
920
  diagnostic_playbook: pb,
269
921
  source: 'typesense',
270
922
  });
@@ -274,34 +926,17 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
274
926
 
275
927
  // If one collection returned results but the other was unavailable/failed/empty, supplement from bundled catalog
276
928
  if ((onlineScenarios.length > 0 && onlineRefDesigns.length === 0) || (onlineRefDesigns.length > 0 && onlineScenarios.length === 0)) {
277
- const tokens = qLower.split(/\s+/).filter(Boolean);
278
929
  if (onlineScenarios.length === 0) {
279
930
  for (const s of core.scenarios || []) {
280
931
  if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
281
932
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
282
933
  if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
283
934
 
284
- const haystack = [s.id, s.title?.zh, s.title?.en, s.subtitle?.zh, s.subtitle?.en, s.summary, ...(s.tags || [])].join(' ').toLowerCase();
935
+ const localizedFields = Object.values(s.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
936
+ 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
937
  const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
286
938
  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
- });
939
+ 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
940
  }
306
941
  }
307
942
  }
@@ -311,30 +946,11 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
311
946
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
312
947
  if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
313
948
 
314
- const haystack = [rd.id, rd.title?.zh, rd.title?.en, rd.summary?.zh, rd.summary?.en, ...(rd.presets || [])].join(' ').toLowerCase();
949
+ const localizedFields = Object.values(rd.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
950
+ 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
951
  const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
316
952
  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
- });
953
+ 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
954
  }
339
955
  }
340
956
  }
@@ -352,10 +968,18 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
352
968
  }
353
969
 
354
970
  if (mergedRecords.length > 0) {
971
+ const onlineWarnings = ['Online search results are partial and do not replace the bundled catalog; use hub get for verified section content.'];
972
+ const hwHint = detectHardwareIntent(tokens, query, core);
973
+ if (hwHint) {
974
+ onlineWarnings.push(hwHint);
975
+ }
355
976
  return createCatalogDto({
356
977
  source: (onlineScenarios.some(r => r.source === 'typesense') || onlineRefDesigns.some(r => r.source === 'typesense')) ? 'typesense' : 'bundled_manifest',
357
- freshness: 'FRESH',
358
- records: mergedRecords,
978
+ freshness: 'UNKNOWN',
979
+ datasetVersion: core.dataset_version,
980
+ scope: 'online_partial_with_bundled_fallback',
981
+ warnings: onlineWarnings,
982
+ records: mergedRecords.map(compactSearchRecord),
359
983
  });
360
984
  }
361
985
  } catch {
@@ -368,39 +992,31 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
368
992
  const matchedScenarios = [];
369
993
  const matchedRefDesigns = [];
370
994
 
371
- const tokens = qLower.split(/\s+/).filter(Boolean);
372
-
373
995
  for (const s of core.scenarios || []) {
374
996
  if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
375
997
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
376
998
  if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
377
999
 
1000
+ const localizedFields = Object.values(s.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
378
1001
  const haystack = [
379
1002
  s.id,
380
1003
  s.title?.zh,
381
1004
  s.title?.en,
1005
+ s.title?.ja,
382
1006
  s.subtitle?.zh,
383
1007
  s.subtitle?.en,
1008
+ s.subtitle?.ja,
384
1009
  s.summary,
1010
+ ...localizedFields,
385
1011
  ...(s.tags || []),
386
1012
  ].join(' ').toLowerCase();
387
1013
 
388
1014
  const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
389
1015
  if (tokens.length === 0 || score > 0) {
390
1016
  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 || '',
1017
+ ...bundledSearchProjection({ ...s, summary: s.subtitle || s.summary }, recordType(s, 'scenario'), locale),
395
1018
  tags: s.tags || [],
396
1019
  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
1020
  repo_url: s.repo_url || '',
405
1021
  diagnostic_playbook: s.diagnostic_playbook || null,
406
1022
  score,
@@ -413,29 +1029,24 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
413
1029
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
414
1030
  if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
415
1031
 
1032
+ const localizedFields = Object.values(rd.locale_content || {}).flatMap((content) => [content?.title, content?.summary]);
416
1033
  const haystack = [
417
1034
  rd.id,
418
1035
  rd.title?.zh,
419
1036
  rd.title?.en,
1037
+ rd.title?.ja,
420
1038
  rd.summary?.zh,
421
1039
  rd.summary?.en,
1040
+ rd.summary?.ja,
1041
+ ...localizedFields,
422
1042
  ...(rd.presets || []),
423
1043
  ].join(' ').toLowerCase();
424
1044
 
425
1045
  const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
426
1046
  if (tokens.length === 0 || score > 0) {
427
1047
  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 || '',
1048
+ ...bundledSearchProjection(rd, 'reference_design', locale),
432
1049
  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
1050
  wiki_url: rd.wiki_url ? appendUtm(rd.wiki_url, {
440
1051
  source: 'sensecraft_agent',
441
1052
  medium: 'tech_wiki',
@@ -463,13 +1074,25 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
463
1074
  }
464
1075
  }
465
1076
 
1077
+ const warnings = process.env.SENSECRAFT_OFFLINE_ONLY
1078
+ ? ['Offline-only mode: displaying the verified bundled catalog; no online request was attempted.']
1079
+ : ((keySolution || keyRefDesign)
1080
+ ? ['Online Typesense request timed out or returned no hits. Displaying verified local catalog.']
1081
+ : ['No Typesense API key configured or baked. Displaying verified local catalog.']);
1082
+
1083
+ // Detect if query looks like a hardware device, model, or SKU keyword
1084
+ const hwHint = detectHardwareIntent(tokens, query, core);
1085
+ if (hwHint && (combined.length === 0 || (combined[0] && combined[0].score < 2))) {
1086
+ warnings.push(hwHint);
1087
+ }
1088
+
466
1089
  return createCatalogDto({
467
1090
  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.'],
1091
+ freshness: 'UNKNOWN',
1092
+ datasetVersion: core.dataset_version,
1093
+ scope: 'bundled_catalog_snapshot',
1094
+ records: combined.map(compactSearchRecord),
1095
+ warnings,
473
1096
  });
474
1097
  }
475
1098
 
@@ -500,8 +1123,12 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
500
1123
  records: [{
501
1124
  family_id: family.id,
502
1125
  title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1126
+ summary: family.summary || null,
503
1127
  default_sku: family.default_sku,
504
1128
  regions: family.regions || [],
1129
+ interfaces: family.interfaces || [],
1130
+ protocols: family.protocols || [],
1131
+ supported_scenarios: family.supported_scenarios || [],
505
1132
  skus_count: (family.skus || []).length,
506
1133
  skus: (family.skus || []).slice(0, 10).map((s) => ({
507
1134
  ...s,
@@ -519,16 +1146,376 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
519
1146
  });
520
1147
  }
521
1148
 
1149
+ /**
1150
+ * Searches product families and member SKUs by keywords, model name, interface, or SKU number.
1151
+ *
1152
+ * @param {Object} options
1153
+ * @param {string} options.query - Free text query (e.g. "J4012 Super", "RS485", "114110314")
1154
+ * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
1155
+ * @param {number} [options.limit=5]
1156
+ * @returns {Object} Catalog DTO
1157
+ */
1158
+ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1159
+ const core = loadKnowledgeCore();
1160
+ const families = Object.values(core.product_families || {});
1161
+ const qLower = (query || '').toLowerCase().trim();
1162
+ const tokens = qLower.split(/\s+/).filter(Boolean);
1163
+
1164
+ if (tokens.length === 0) {
1165
+ return createCatalogDto({
1166
+ source: 'bundled_manifest',
1167
+ freshness: 'FRESH',
1168
+ records: [],
1169
+ warnings: ['Query cannot be empty.'],
1170
+ });
1171
+ }
1172
+
1173
+ const matches = [];
1174
+
1175
+ for (const fam of families) {
1176
+ let score = 0;
1177
+ const titleZh = (fam.title?.zh || '').toLowerCase();
1178
+ const titleEn = (fam.title?.en || '').toLowerCase();
1179
+ const fid = (fam.id || '').toLowerCase();
1180
+ const famHaystack = [fid, titleZh, titleEn].join(' ');
1181
+
1182
+ // Check SKU matching
1183
+ const matchedSkus = [];
1184
+ for (const sku of fam.skus || []) {
1185
+ const sNum = String(sku.sku || '').toLowerCase();
1186
+ const sModel = String(sku.model || '').toLowerCase();
1187
+ const sName = String(sku.name || '').toLowerCase();
1188
+ // Strip negative clauses like "(Super Mode NOT Supported)" from positive keyword matching
1189
+ const sNameClean = sName
1190
+ .replace(/[(\(][^)\)]*not\s+supported[^)\)]*[)\)]?/gi, '')
1191
+ .replace(/super\s+mode\s+not\s+supported/gi, '')
1192
+ .replace(/\b(?:not\s+supported|unsupported)\b/gi, '');
1193
+ const sAttrs = Object.values(sku.attrs || {}).map((v) => String(v).toLowerCase());
1194
+ const skuIfaces = (sku.specs?.connectivity?.interfaces || sku.interfaces || []).map((i) => String(i).toLowerCase());
1195
+ const skuProtos = (sku.specs?.connectivity?.protocols || sku.protocols || []).map((p) => String(p).toLowerCase());
1196
+ const skuIoPorts = (sku.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1197
+
1198
+ const skuHaystack = [sNum, sModel, sNameClean, ...sAttrs, ...skuIfaces, ...skuProtos, ...skuIoPorts].join(' ');
1199
+ const isSingleSkuLookup = tokens.length === 1 && (sNum === tokens[0] || sNum === qLower);
1200
+ const skuMatchesAll = tokens.every((t) => skuHaystack.includes(t));
1201
+ const singleTokenHit = tokens.length === 1 && ((sModel && sModel.includes(tokens[0])) || skuHaystack.includes(tokens[0]));
1202
+
1203
+ if (isSingleSkuLookup || skuMatchesAll) {
1204
+ matchedSkus.push(sku);
1205
+ } else if (tokens.length > 1) {
1206
+ const combinedMatchesAll = tokens.every((t) => (famHaystack + ' ' + skuHaystack).includes(t));
1207
+ const nonFamTokens = tokens.filter((t) => !famHaystack.includes(t));
1208
+ if (combinedMatchesAll && (nonFamTokens.length === 0 || nonFamTokens.every((t) => skuHaystack.includes(t)))) {
1209
+ matchedSkus.push(sku);
1210
+ }
1211
+ } else if (singleTokenHit) {
1212
+ matchedSkus.push(sku);
1213
+ }
1214
+ }
1215
+
1216
+ // Require all query tokens to be satisfied either in the family or across member SKUs
1217
+ const famMatchesAll = tokens.every((t) => famHaystack.includes(t));
1218
+ const allTokensSatisfiedInFamily = tokens.every((t) => {
1219
+ return famHaystack.includes(t) || (fam.skus || []).some((s) => {
1220
+ const sNameClean = String(s.name || '')
1221
+ .toLowerCase()
1222
+ .replace(/[(\(][^)\)]*not\s+supported[^)\)]*[)\)]?/gi, '')
1223
+ .replace(/super\s+mode\s+not\s+supported/gi, '');
1224
+ const sIfaces = (s.specs?.connectivity?.interfaces || s.interfaces || []).map((i) => String(i).toLowerCase());
1225
+ const sProtos = (s.specs?.connectivity?.protocols || s.protocols || []).map((p) => String(p).toLowerCase());
1226
+ const sPorts = (s.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1227
+ return [s.sku, s.model, sNameClean, ...Object.values(s.attrs || {}), ...sIfaces, ...sProtos, ...sPorts].join(' ').toLowerCase().includes(t);
1228
+ });
1229
+ });
1230
+
1231
+ if (!allTokensSatisfiedInFamily) {
1232
+ continue;
1233
+ }
1234
+
1235
+ if (matchedSkus.length > 0 || famMatchesAll) {
1236
+ score = matchedSkus.length * 10 + (famMatchesAll ? 5 : 0);
1237
+ matches.push({
1238
+ family: fam,
1239
+ score,
1240
+ matchedSkus: matchedSkus.length > 0 ? matchedSkus : (fam.skus || []).slice(0, 3),
1241
+ });
1242
+ }
1243
+ }
1244
+
1245
+ matches.sort((a, b) => b.score - a.score);
1246
+
1247
+ const records = matches.slice(0, limit).map(({ family, matchedSkus }) => {
1248
+ return {
1249
+ family_id: family.id,
1250
+ title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1251
+ summary: family.summary || null,
1252
+ default_sku: family.default_sku,
1253
+ interfaces: family.interfaces || [],
1254
+ protocols: family.protocols || [],
1255
+ supported_scenarios: family.supported_scenarios || [],
1256
+ matched_skus: matchedSkus.map((s) => {
1257
+ const specs = s.specs || {};
1258
+ const pwr = specs.power || {};
1259
+ const pkg = specs.package_contents || [];
1260
+ const pkgText = pkg.join(' ').toLowerCase();
1261
+
1262
+ // Require positive evidence of an included operational battery (not just battery_powered capability or RTC battery)
1263
+ const hasIncludedBattery = pkg.some((item) => {
1264
+ if (!/\bbattery\b/i.test(item)) return false;
1265
+ if (/(?:not\s+included|sold\s+separately|cr1220|rtc|coin)/i.test(item)) return false;
1266
+ if (/(?:without|w\/o|no|exclude[ds]?)\s+[^()]*\bbattery/i.test(item)) return false;
1267
+ return true;
1268
+ });
1269
+
1270
+ let adapterIncluded = pwr.adapter_included;
1271
+ if (adapterIncluded === undefined || adapterIncluded === null) {
1272
+ if (s.attrs?.power_adapter === 'not_included' || /w\/?o\s+power/i.test(s.name)) {
1273
+ adapterIncluded = false;
1274
+ } else if (s.attrs?.power_adapter === 'included' || /power\s+adapter/i.test(pkgText)) {
1275
+ adapterIncluded = true;
1276
+ } else {
1277
+ adapterIncluded = hasIncludedBattery ? false : null;
1278
+ }
1279
+ }
1280
+
1281
+ const cordExplicitlyExcluded =
1282
+ s.attrs?.power_adapter === 'included_no_cord' ||
1283
+ s.attrs?.power_cord === 'not_included' ||
1284
+ /without\s+power\s+cord/i.test(pkgText) ||
1285
+ /no\s+power\s+cord/i.test(pkgText) ||
1286
+ /w\/?o\s+power\s+cord/i.test(pkgText) ||
1287
+ /power\s+cord\s+(?:sold\s+separately|not\s+included|optional|excluded)/i.test(pkgText) ||
1288
+ /without\s+power\s+cord/i.test(pwr.adapter_spec || '') ||
1289
+ /without\s+power\s+cord/i.test(s.name || '');
1290
+
1291
+ let powerCordIncluded = null;
1292
+ if (cordExplicitlyExcluded) {
1293
+ powerCordIncluded = false;
1294
+ } else if (s.attrs?.power_cord === 'included') {
1295
+ powerCordIncluded = true;
1296
+ } else if (pkg.length > 0) {
1297
+ const hasExplicitCord = pkg.some((item) => {
1298
+ const itemLower = item.toLowerCase();
1299
+ return /power\s+cord/i.test(itemLower) &&
1300
+ !/(?:without|sold\s+separately|not\s+included|optional|no\s+|w\/o|separately)/i.test(itemLower);
1301
+ });
1302
+ if (hasExplicitCord) {
1303
+ powerCordIncluded = true;
1304
+ } else if (adapterIncluded === true) {
1305
+ const isIntegratedPlug =
1306
+ /universal|wall\s*mount|plug/i.test(pwr.adapter_spec || '') ||
1307
+ /universal\s+power\s+supply/i.test(pkgText) ||
1308
+ /(?:with\s+[^()]*|\b(?:interchangeable|multi-country|global)\s+)plugs?\b/i.test(pkgText);
1309
+ if (isIntegratedPlug) {
1310
+ powerCordIncluded = true;
1311
+ } else {
1312
+ powerCordIncluded = null;
1313
+ }
1314
+ }
1315
+ }
1316
+
1317
+ let outOfTheBoxReady = false;
1318
+ let readinessSummary = '';
1319
+
1320
+ const isDevBoard = /xiao|seeeduino|arduino/i.test(family.id) || /board|development\s+kit/i.test(s.name) || (pwr.input_voltage && /3\.7|5v/i.test(pwr.input_voltage));
1321
+
1322
+ if (hasIncludedBattery) {
1323
+ outOfTheBoxReady = true;
1324
+ readinessSummary = `随附配套电池供电(${pwr.input_connector ? '支持 ' + pwr.input_connector : '支持太阳能/USB充电'}),开箱即可直接上电运行。`;
1325
+ } else if (adapterIncluded === false) {
1326
+ outOfTheBoxReady = false;
1327
+ if (isDevBoard) {
1328
+ readinessSummary = `开发板/套件(仅含主板与配件),需通过 ${pwr.input_connector || 'USB-C'} 供电或自备外接电源/电池。`;
1329
+ } else {
1330
+ readinessSummary = `仅含主机,不随附电源适配器。用户买回去无法直接开机,必须加配 ${pwr.adapter_spec || '合适规格电源适配器'}。`;
1331
+ }
1332
+ } else if (adapterIncluded === true) {
1333
+ if (powerCordIncluded === false) {
1334
+ outOfTheBoxReady = false;
1335
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),但不随附 AC 电源线,开箱前需自备或加配电源线。`;
1336
+ } else if (powerCordIncluded === true) {
1337
+ outOfTheBoxReady = true;
1338
+ readinessSummary = `随附电源适配器与电源线(或一体式插头),开箱即用。`;
1339
+ } else {
1340
+ outOfTheBoxReady = false;
1341
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),包装清单未明确电源线随附情况,请核对是否需自备电源线。`;
1342
+ }
1343
+ } else {
1344
+ outOfTheBoxReady = false;
1345
+ readinessSummary = pwr.input_voltage ? `供电规格: ${pwr.input_voltage} ${pwr.input_connector || ''},请核对是否自备电源。` : '电源随附情况未声明。';
1346
+ }
1347
+
1348
+ // Get matching required accessories
1349
+ const requiredAccessories = (family.required_accessories || []).filter((acc) => {
1350
+ if (!acc.applies_when) return true;
1351
+ const attrVal = s.attrs?.[acc.applies_when.attr];
1352
+ const target = acc.applies_when.value;
1353
+ if (Array.isArray(target)) return target.includes(attrVal);
1354
+ return attrVal === target;
1355
+ }).map((acc) => ({
1356
+ id: acc.id,
1357
+ name: acc.name,
1358
+ sku: acc.sku,
1359
+ reason: acc.reason,
1360
+ url: acc.url,
1361
+ }));
1362
+
1363
+ return {
1364
+ sku: s.sku,
1365
+ model: s.model || null,
1366
+ name: s.name,
1367
+ url: appendUtm(s.url, {
1368
+ source: 'sensecraft_agent',
1369
+ medium: 'product_search',
1370
+ campaign: family.id,
1371
+ content: `sku_${s.sku}`,
1372
+ }),
1373
+ package_profile: {
1374
+ power_adapter_included: adapterIncluded,
1375
+ power_cord_included: powerCordIncluded,
1376
+ out_of_the_box_ready: outOfTheBoxReady,
1377
+ readiness_summary: readinessSummary,
1378
+ },
1379
+ required_accessories: requiredAccessories,
1380
+ power: {
1381
+ adapter_included: adapterIncluded,
1382
+ adapter_spec: pwr.adapter_spec || null,
1383
+ input_voltage: pwr.input_voltage || null,
1384
+ input_connector: pwr.input_connector || null,
1385
+ },
1386
+ package_contents: pkg,
1387
+ interfaces: specs.connectivity?.interfaces || family.interfaces || [],
1388
+ io_ports: specs.io_ports || [],
1389
+ expansion_slots: specs.expansion_slots || [],
1390
+ ai_performance: specs.ai_performance || null,
1391
+ };
1392
+ }),
1393
+ };
1394
+ });
1395
+
1396
+ return createCatalogDto({
1397
+ source: 'bundled_manifest',
1398
+ freshness: 'FRESH',
1399
+ records,
1400
+ warnings: records.length === 0 ? [`未找到与 "${query}" 匹配的硬件产品或 SKU。`] : [],
1401
+ });
1402
+ }
1403
+
522
1404
  /**
523
1405
  * Queries and verifies accessories for a product family, running physical compatibility checks.
524
1406
  *
525
1407
  * @param {string} familyId
526
1408
  * @param {Object} [options]
1409
+ /**
1410
+ * Bazaar bulk-add-cart supports simple numeric SKUs.
1411
+ *
1412
+ * @param {string|null|undefined} sku
1413
+ * @returns {boolean}
1414
+ */
1415
+ export function isCartableSku(sku) {
1416
+ if (!sku) return false;
1417
+ return /^\d+$/.test(String(sku).trim());
1418
+ }
1419
+
1420
+ /**
1421
+ * Storefront domain per site locale (ja -> jp.seeedstudio.com, else www.seeedstudio.com).
1422
+ *
1423
+ * @param {string} [locale='en']
1424
+ * @returns {string}
1425
+ */
1426
+ export function storeBaseUrlForLocale(locale) {
1427
+ return locale === 'ja'
1428
+ ? 'https://jp.seeedstudio.com'
1429
+ : 'https://www.seeedstudio.com';
1430
+ }
1431
+
1432
+ /**
1433
+ * Chinese locale does not check out on global store.
1434
+ *
1435
+ * @param {string} [locale]
1436
+ * @returns {boolean}
1437
+ */
1438
+ export function localeSupportsCart(locale) {
1439
+ return locale !== 'zh-hans' && locale !== 'zh';
1440
+ }
1441
+
1442
+ /**
1443
+ * Builds Seeed Bazaar bulk-add-cart deep link with UTM attribution.
1444
+ *
1445
+ * @param {Array<{sku: string, qty: number}>} items
1446
+ * @param {Object} [options]
1447
+ * @param {string} [options.solutionSlug='solution']
1448
+ * @param {string} [options.locale='en']
1449
+ * @param {string} [options.requestId]
1450
+ * @returns {string|null}
1451
+ */
1452
+ export function buildBulkAddCartUrl(items, { solutionSlug = 'solution', locale = 'en', requestId = null } = {}) {
1453
+ const validItems = (items || [])
1454
+ .filter((it) => it && isCartableSku(it.sku) && Number.isInteger(it.qty) && it.qty > 0)
1455
+ .map((it) => `${it.sku}:${it.qty}`);
1456
+
1457
+ if (validItems.length === 0) return null;
1458
+ if (!localeSupportsCart(locale)) return null;
1459
+
1460
+ const reqId = requestId || `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1461
+ const medium = encodeURIComponent(`Seeed::Solution::${solutionSlug}::sample-purchase`);
1462
+ const baseUrl = storeBaseUrlForLocale(locale);
1463
+ const rawUrl = `${baseUrl}/bulk-add-cart?items=${validItems.join(',')}&requestId=${encodeURIComponent(reqId)}&c_event_medium=${medium}`;
1464
+
1465
+ return appendUtm(rawUrl, {
1466
+ source: 'sensecraft_agent',
1467
+ medium: 'solution_bom',
1468
+ campaign: solutionSlug,
1469
+ content: 'sample_purchase',
1470
+ });
1471
+ }
1472
+
1473
+ /**
1474
+ * Evaluates whether an accessory with applies_when condition matches the selected host SKU.
1475
+ *
1476
+ * @param {Object} acc - Accessory definition
1477
+ * @param {string} hostSku - Selected host SKU
1478
+ * @param {Object} family - Product family object
1479
+ * @returns {boolean}
1480
+ */
1481
+ export function isAccessoryApplicable(acc, hostSku, family) {
1482
+ if (!acc || !acc.applies_when) {
1483
+ return true;
1484
+ }
1485
+ const { attr, value } = acc.applies_when;
1486
+ if (!attr) return true;
1487
+
1488
+ const skuObj = (family?.skus || []).find((s) => s.sku === hostSku);
1489
+ const actualValue = skuObj?.attrs?.[attr];
1490
+
1491
+ if (actualValue === undefined || actualValue === null) {
1492
+ const fallbackValue = family?.[attr] ?? family?.physical_spec?.[attr];
1493
+ if (fallbackValue !== undefined && fallbackValue !== null) {
1494
+ return Array.isArray(value) ? value.includes(fallbackValue) : value === fallbackValue;
1495
+ }
1496
+ if (skuObj && skuObj.attrs) {
1497
+ return false;
1498
+ }
1499
+ return true;
1500
+ }
1501
+
1502
+ if (Array.isArray(value)) {
1503
+ return value.includes(actualValue);
1504
+ }
1505
+ return value === actualValue;
1506
+ }
1507
+
1508
+ /**
1509
+ * Queries accessories for a given product family and evaluates compatibility.
1510
+ *
1511
+ * @param {string} familyId - Product family ID
1512
+ * @param {Object} [options]
527
1513
  * @param {string} [options.sku] - Specific host SKU
528
1514
  * @param {string} [options.locale='zh']
1515
+ * @param {boolean} [options.compact=false] - Return token-efficient compact payload
529
1516
  * @returns {Object} Catalog DTO with structured, verified accessories
530
1517
  */
531
- export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
1518
+ export function queryAccessories(familyId, { sku = null, locale = 'zh', compact = false, category = null } = {}) {
532
1519
  const core = loadKnowledgeCore();
533
1520
  const family = core.product_families?.[familyId];
534
1521
 
@@ -560,11 +1547,13 @@ export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
560
1547
  qty: acc.qty || 1,
561
1548
  reason: acc.reason || 'Required for standard operation',
562
1549
  applies_when: acc.applies_when || null,
563
- compatibility: {
564
- is_compatible: comp.isCompatible,
565
- status: comp.status,
566
- details: comp.dimensions,
567
- },
1550
+ compatibility: compact
1551
+ ? { is_compatible: comp.isCompatible, status: comp.status }
1552
+ : {
1553
+ is_compatible: comp.isCompatible,
1554
+ status: comp.status,
1555
+ details: comp.dimensions,
1556
+ },
568
1557
  };
569
1558
  });
570
1559
 
@@ -583,25 +1572,73 @@ export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
583
1572
  content: `optional_${acc.id}`,
584
1573
  }),
585
1574
  type: acc.type || 'peripheral',
586
- compatibility: {
587
- is_compatible: comp.isCompatible,
588
- status: comp.status,
589
- details: comp.dimensions,
590
- },
1575
+ compatibility: compact
1576
+ ? { is_compatible: comp.isCompatible, status: comp.status }
1577
+ : {
1578
+ is_compatible: comp.isCompatible,
1579
+ status: comp.status,
1580
+ details: comp.dimensions,
1581
+ },
591
1582
  };
592
1583
  });
593
1584
 
1585
+ let filteredRequired = required;
1586
+ let filteredOptional = optional;
1587
+
1588
+ if (category) {
1589
+ const catTokens = String(category).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
1590
+ if (catTokens.length > 0) {
1591
+ filteredRequired = required.filter((acc) => {
1592
+ if (catTokens.includes('required')) return true;
1593
+ if (catTokens.includes('optional')) return false;
1594
+ const txt = [acc.id, acc.name, acc.sku, acc.category, acc.reason].join(' ').toLowerCase();
1595
+ return catTokens.some((tok) => txt.includes(tok));
1596
+ });
1597
+ filteredOptional = optional.filter((acc) => {
1598
+ if (catTokens.includes('optional')) return true;
1599
+ if (catTokens.includes('required')) return false;
1600
+ const txt = [acc.id, acc.name, acc.sku, acc.category, acc.type].join(' ').toLowerCase();
1601
+ return catTokens.some((tok) => txt.includes(tok));
1602
+ });
1603
+ }
1604
+ }
1605
+
1606
+ const hostSku = sku || family.default_sku;
1607
+ const cartItems = [];
1608
+ if (isCartableSku(hostSku)) {
1609
+ cartItems.push({ sku: hostSku, qty: 1 });
1610
+ }
1611
+ for (const req of filteredRequired) {
1612
+ const isApplicable = isAccessoryApplicable(req, hostSku, family);
1613
+ req.applicable = isApplicable;
1614
+ if (isApplicable && isCartableSku(req.sku)) {
1615
+ cartItems.push({ sku: req.sku, qty: req.qty || 1 });
1616
+ }
1617
+ }
1618
+
1619
+ const bundleCartUrl = buildBulkAddCartUrl(cartItems, {
1620
+ solutionSlug: family.id,
1621
+ locale,
1622
+ });
1623
+
594
1624
  return createCatalogDto({
595
1625
  source: 'bundled_manifest',
596
1626
  freshness: 'FRESH',
597
1627
  records: [{
598
1628
  family_id: family.id,
599
1629
  host_title: family.title?.[locale] || family.title?.zh || family.title?.en,
600
- host_sku: sku || family.default_sku,
1630
+ host_sku: hostSku,
601
1631
  physical_spec: hostPhysicalSpec,
1632
+ bundle_cart_url: bundleCartUrl,
1633
+ ...(bundleCartUrl ? {} : {
1634
+ domestic_channel_note: locale === 'zh'
1635
+ ? '国内采购建议通过 Seeed 官方直营店铺或授权分销商采购(非国际站美金结算)'
1636
+ : null,
1637
+ }),
1638
+ ...(category ? { filtered_by_category: category } : {}),
602
1639
  accessories: {
603
- required,
604
- optional,
1640
+ required: filteredRequired,
1641
+ optional: filteredOptional,
605
1642
  },
606
1643
  }],
607
1644
  });