@waterplus-ai/waterbuddy 0.1.38 → 0.1.78

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 (71) hide show
  1. package/assets/experts/digital/it-assessment.jpg +0 -0
  2. package/assets/experts/digital/it-assessment.md +42 -0
  3. package/assets/experts/digital/smart-water-assistant.jpg +0 -0
  4. package/assets/experts/digital/smart-water-assistant.md +42 -0
  5. package/assets/experts/digital/smart-water-case-expert.jpg +0 -0
  6. package/assets/experts/digital/smart-water-case-expert.md +45 -0
  7. package/assets/experts/digital/water-industry-brain.jpg +0 -0
  8. package/assets/experts/digital/water-industry-brain.md +46 -0
  9. package/assets/experts/drainage/drainage-sewage-ops.jpg +0 -0
  10. package/assets/experts/drainage/drainage-sewage-ops.md +32 -0
  11. package/assets/experts/drainage/flood-control-pumping.jpg +0 -0
  12. package/assets/experts/drainage/flood-control-pumping.md +42 -0
  13. package/assets/experts/general/article-writing.jpg +0 -0
  14. package/assets/experts/general/article-writing.md +32 -0
  15. package/assets/experts/general/award-application.jpg +0 -0
  16. package/assets/experts/general/award-application.md +32 -0
  17. package/assets/experts/general/benchmarking.jpg +0 -0
  18. package/assets/experts/general/benchmarking.md +42 -0
  19. package/assets/experts/general/data-analysis.jpg +0 -0
  20. package/assets/experts/general/data-analysis.md +32 -0
  21. package/assets/experts/general/evaluation-assistant.jpg +0 -0
  22. package/assets/experts/general/evaluation-assistant.md +32 -0
  23. package/assets/experts/general/event-planning.jpg +0 -0
  24. package/assets/experts/general/event-planning.md +32 -0
  25. package/assets/experts/general/excel-formula.jpg +0 -0
  26. package/assets/experts/general/excel-formula.md +32 -0
  27. package/assets/experts/general/it-coding.jpg +0 -0
  28. package/assets/experts/general/it-coding.md +32 -0
  29. package/assets/experts/general/meeting-minutes.jpg +0 -0
  30. package/assets/experts/general/meeting-minutes.md +32 -0
  31. package/assets/experts/general/summarization.jpg +0 -0
  32. package/assets/experts/general/summarization.md +32 -0
  33. package/assets/experts/general/video-script.jpg +0 -0
  34. package/assets/experts/general/video-script.md +32 -0
  35. package/assets/experts/general/work-report.jpg +0 -0
  36. package/assets/experts/general/work-report.md +32 -0
  37. package/assets/experts/management/admin-party-affairs.jpg +0 -0
  38. package/assets/experts/management/admin-party-affairs.md +42 -0
  39. package/assets/experts/management/group-management-assistant.jpg +0 -0
  40. package/assets/experts/management/group-management-assistant.md +42 -0
  41. package/assets/experts/management/hr-performance.jpg +0 -0
  42. package/assets/experts/management/hr-performance.md +42 -0
  43. package/assets/experts/management/procurement-contract-risk.jpg +0 -0
  44. package/assets/experts/management/procurement-contract-risk.md +32 -0
  45. package/assets/experts/operations/customer-service.jpg +0 -0
  46. package/assets/experts/operations/customer-service.md +32 -0
  47. package/assets/experts/operations/metering-revenue.jpg +0 -0
  48. package/assets/experts/operations/metering-revenue.md +42 -0
  49. package/assets/experts/policy/smart-water-standards.jpg +0 -0
  50. package/assets/experts/policy/smart-water-standards.md +46 -0
  51. package/assets/experts/policy/water-pricing.jpg +0 -0
  52. package/assets/experts/policy/water-pricing.md +46 -0
  53. package/assets/experts/quality/plant-water-quality.jpg +0 -0
  54. package/assets/experts/quality/plant-water-quality.md +32 -0
  55. package/assets/experts/supply/construction-safety.jpg +0 -0
  56. package/assets/experts/supply/construction-safety.md +42 -0
  57. package/assets/experts/supply/energy-equipment-ops.jpg +0 -0
  58. package/assets/experts/supply/energy-equipment-ops.md +32 -0
  59. package/assets/experts/supply/inspection-repair.jpg +0 -0
  60. package/assets/experts/supply/inspection-repair.md +32 -0
  61. package/assets/experts/supply/network-asset-leakage.jpg +0 -0
  62. package/assets/experts/supply/network-asset-leakage.md +42 -0
  63. package/assets/experts/supply/water-source-dispatch.jpg +0 -0
  64. package/assets/experts/supply/water-source-dispatch.md +42 -0
  65. package/lib/client/index.js +1018 -16
  66. package/lib/host/expert-store.js +245 -0
  67. package/lib/host/expert-tools.js +416 -0
  68. package/lib/host/experts.js +318 -0
  69. package/lib/host/index.js +276 -2
  70. package/lib/host/llm-gateway.js +12 -3
  71. package/package.json +9 -4
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
18
18
  }
19
19
 
20
20
  function WaterBuddyName() {
21
+ // 只保留蓝色字标;不再显示 「Powered by DSH」。
21
22
  return jsxs(Fragment, {
22
23
  children: [
23
24
  jsx('strong', {
@@ -31,17 +32,7 @@ window.__ModuleLoader__.load({
31
32
  },
32
33
  children: 'WaterBuddy',
33
34
  }),
34
- jsx('small', {
35
- style: {
36
- color: 'var(--dsw-text-tertiary, #7b8494)',
37
- fontSize: 10,
38
- fontWeight: 500,
39
- opacity: 0.7,
40
- whiteSpace: 'nowrap',
41
- },
42
- children: 'Powered by DSH',
43
- }),
44
- ],
35
+ ],
45
36
  })
46
37
  }
47
38
 
@@ -507,11 +498,12 @@ window.__ModuleLoader__.load({
507
498
  const logout = async () => { await fetch('/api/waterbuddy/logout', { method: 'POST' }); setUser(null); setOpen(false); setSession({ qrCode: '', status: '', error: '', loading: false }) }
508
499
  const name = userName(user)
509
500
  const buttonStyle = { borderRadius: 10, padding: '9px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer' }
510
- // 额度以美元记账、按人民币向用户展示;折算与格式化都在 Host 侧完成
501
+ // 额度以美元记账、按积分向用户展示;折算与格式化都在 Host 侧完成
511
502
  // (见 formatBalanceText),客户端只渲染文本,不自己拿汇率算。
503
+ // Host 未给文本时用占位符,不回落到金额——展示口径由 Host 独占,避免两处不一致。
512
504
  const balanceText = typeof llm?.balanceText === 'string' && llm.balanceText
513
505
  ? llm.balanceText
514
- : `$${Number(llm?.balance || 0).toFixed(2)}`
506
+ : '--'
515
507
  // 用户把默认模型换成别人的提供方时,消耗的不是我们的额度,一律不展示额度。
516
508
  const managesQuota = llm?.managesQuota === true
517
509
  const exhausted = managesQuota && llm?.exhausted === true
@@ -527,7 +519,10 @@ window.__ModuleLoader__.load({
527
519
  const isAdmin = user?.isAdmin === 1 || user?.isAdmin === true
528
520
  return jsxs(Fragment, { children: [
529
521
  jsx('div', { style: { position: 'relative', padding: '28px 28px 44px', textAlign: 'center', color: '#fff', background: 'linear-gradient(135deg, #0f6fd6 0%, #1d9bf0 58%, #38bdf8 100%)' }, children: [
530
- jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: 'rgba(255,255,255,.18)', color: '#fff', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
522
+ // zIndex 必须显式给:标题那两行是**整行宽的块级元素**,盒子右端一直伸到按钮
523
+ // 下方,而它们在 DOM 里排在按钮之后(后绘制者在上),会抢走按钮中下部的点击
524
+ // ——表现为「上半能点、下半点不中」。定位元素给了 z-index 才稳定盖住它们。
525
+ jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', zIndex: 1, top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: 'rgba(255,255,255,.18)', color: '#fff', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
531
526
  jsx('div', { style: { fontSize: 12, opacity: .82, letterSpacing: 1 }, children: 'WATERBUDDY' }),
532
527
  jsx('div', { style: { marginTop: 8, fontSize: 15, fontWeight: 600 }, children: 'WaterBuddy' }),
533
528
  jsx('div', { style: { position: 'absolute', left: '50%', bottom: -35, transform: 'translateX(-50%)', padding: 5, borderRadius: '50%', background: '#fff', boxShadow: '0 6px 18px rgba(15,23,42,.18)' }, children: jsx(UserAvatar, { user, size: 62 }) }),
@@ -565,7 +560,8 @@ window.__ModuleLoader__.load({
565
560
  }),
566
561
  open ? jsx('div', { style: { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15, 23, 42, .42)', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: event => { if (event.target === event.currentTarget) setOpen(false) }, children: jsx('div', { style: { width: 420, maxWidth: 'calc(100vw - 32px)', borderRadius: 22, background: '#fff', overflow: 'hidden', boxShadow: '0 24px 80px rgba(15,23,42,.28)' }, children: user ? userPanel() : jsxs('div', { children: [
567
562
  jsxs('div', { style: { position: 'relative', padding: '22px 26px 0', textAlign: 'center' }, children: [
568
- jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: '#f1f5f9', color: '#64748b', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
563
+ // 与已登录面板同一个坑:标题/说明是整行宽的块级元素,必须靠 z-index 让按钮稳定在上。
564
+ jsx('button', { type: 'button', onClick: () => setOpen(false), 'aria-label': '关闭', style: { position: 'absolute', zIndex: 1, top: 14, right: 16, width: 30, height: 30, border: 0, borderRadius: '50%', background: '#f1f5f9', color: '#64748b', fontSize: 20, lineHeight: 1, cursor: 'pointer' }, children: '×' }),
569
565
  jsx('img', { src: SSO.image, alt: SSO.title, style: { display: 'block', width: 52, height: 52, objectFit: 'contain', margin: '0 auto' } }),
570
566
  jsx('h3', { style: { margin: '10px 0 4px', color: '#162033', fontSize: 21 }, children: 'WaterBuddy' }),
571
567
  jsx('p', { style: { margin: 0, color: '#7b8799', fontSize: 12.5 }, children: '登录后同步水务之窗账号与知识库权限' }),
@@ -584,7 +580,905 @@ window.__ModuleLoader__.load({
584
580
  return null
585
581
  }
586
582
 
587
- const inject = ['slots']
583
+ /**
584
+ * 写剪贴板。
585
+ *
586
+ * `navigator.clipboard` 只在安全上下文可用,而且在内嵌 webview 里可能被权限策略挡掉;
587
+ * 这里落到 `execCommand('copy')` 兜底(已废弃,但仍是 webview 里最可靠的一条路)。
588
+ * @param text - 要复制的内容。
589
+ * @throws 两条路都不通时抛出,由调用方提示用户。
590
+ */
591
+ async function writeClipboard(text) {
592
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
593
+ try {
594
+ await navigator.clipboard.writeText(text)
595
+ return
596
+ } catch { /* 落到下面的兜底 */ }
597
+ }
598
+ // 兜底:WebKit 对"完全不可见"的 textarea 常常拒绝复制,所以留 1px 与极小透明度,
599
+ // 并且必须 focus + select 之后再 execCommand。整段仍是同步的,手势不会丢。
600
+ const area = document.createElement('textarea')
601
+ area.value = text
602
+ area.setAttribute('readonly', '')
603
+ area.style.position = 'fixed'
604
+ area.style.top = '0'
605
+ area.style.left = '0'
606
+ area.style.width = '1px'
607
+ area.style.height = '1px'
608
+ area.style.padding = '0'
609
+ area.style.border = '0'
610
+ area.style.opacity = '0.01'
611
+ document.body.append(area)
612
+ area.focus()
613
+ area.select()
614
+ const ok = typeof document.execCommand === 'function' && document.execCommand('copy')
615
+ area.remove()
616
+ if (!ok) throw new Error('clipboard-unavailable')
617
+ }
618
+
619
+ /**
620
+ * 新会话 hero 的品牌标记:用我们左上角那个 mark,替换宿主默认的鸟形图标。
621
+ * slot 的 owner props 会给 `size`(正方形边长),照它渲染即可。
622
+ */
623
+ function WaterBuddyHeroMark({ size }) {
624
+ const edge = typeof size === 'number' && size > 0 ? size : 24
625
+ // 与侧边栏左上角同一个 mark(模块级 data URI)。之前这里写的是
626
+ // `/waterplus-mark.png`,该路径在 hero 里取不到,渲染成了黑方块。
627
+ return jsx('img', {
628
+ src: mark,
629
+ alt: 'WaterBuddy',
630
+ width: edge,
631
+ height: edge,
632
+ style: { width: edge, height: edge, objectFit: 'contain', borderRadius: '22%' },
633
+ })
634
+ }
635
+
636
+ // ── locales.js ──────────────────────────────────────────────────────────
637
+
638
+ /** 专家页的样式常量:与插件其它界面一致,全部内联,不引入样式表。 */
639
+ const EXPERT_STYLE = {
640
+ card: { border: '1px solid #e6ecf5', borderRadius: 14, background: '#fff', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 8 },
641
+ field: { height: 34, padding: '0 10px', border: '1px solid #dbe3ee', borderRadius: 9, background: '#fff', color: '#162033', fontSize: 13 },
642
+ primary: { height: 34, padding: '0 16px', border: 0, borderRadius: 9, background: '#2f6fed', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer' },
643
+ ghost: { height: 32, padding: '0 12px', border: '1px solid #dbe3ee', borderRadius: 9, background: '#fff', color: '#334155', fontSize: 12.5, cursor: 'pointer' },
644
+ tab: active => ({ height: 32, padding: '0 14px', border: '1px solid ' + (active ? '#2f6fed' : '#dbe3ee'), borderRadius: 9, background: active ? '#eef4ff' : '#fff', color: active ? '#2f6fed' : '#475569', fontSize: 13, fontWeight: active ? 600 : 400, cursor: 'pointer' }),
645
+ label: { color: '#8a94a6', fontSize: 12, marginBottom: 6 },
646
+ overlay: { position: 'fixed', inset: 0, zIndex: 40, background: 'rgba(15,23,42,.42)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 },
647
+ modal: { width: 'min(720px, 100%)', maxHeight: '80vh', borderRadius: 16, background: '#fff', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
648
+ modalHead: { padding: '14px 18px', borderBottom: '1px solid #eef2f8', display: 'flex', alignItems: 'center', gap: 10 },
649
+ modalBody: { padding: '14px 18px', overflowY: 'auto' },
650
+ modalFoot: { padding: '12px 18px', borderTop: '1px solid #eef2f8', display: 'flex', justifyContent: 'flex-end', gap: 8 },
651
+ pre: { margin: 0, color: '#334155', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'inherit' },
652
+ }
653
+
654
+ // ── catalog.js ───────────────────────────────────────────────────
655
+
656
+ /** 触发器源 id:引用 chip 的 `source` 必须与注册过的源一致,否则宿主无法把 chip 归属到我们。 */
657
+ const EXPERT_SOURCE_ID = division => 'waterbuddy-experts:' + division
658
+
659
+ /** 把专家投影成宿主的原生引用(chip)。slug 只作内部 ref,不进标签、不进剪贴板。 */
660
+ function buildExpertReference(expert) {
661
+ return {
662
+ source: EXPERT_SOURCE_ID(expert.division),
663
+ ref: expert.slug,
664
+ label: expert.name,
665
+ // 新版宿主渲染成内置代理图标;旧宿主忽略该字段并保留默认标记。
666
+ appearance: 'session',
667
+ clipboardText: '@' + expert.name + ' ',
668
+ }
669
+ }
670
+
671
+ /**
672
+ * 取当前会话的输入机,用来写原生引用。
673
+ *
674
+ * 与参考实现同一条路径:`sessions.scope(id)` → `conversation.input.for(actx)`。
675
+ * 宿主没给这些服务时返回 undefined,调用方回落到纯文本插入。
676
+ */
677
+ function resolveExpertInputTarget(ctx, sessionId) {
678
+ if (typeof ctx.get !== 'function') return undefined
679
+ try {
680
+ const sessions = ctx.get('sessions')
681
+ const current = sessionId ?? sessions?.list?.getSnapshot?.().current
682
+ if (current === undefined) return undefined
683
+ const actx = sessions.scope?.(current) ?? sessions.binding?.(current)?.ctx
684
+ if (actx === undefined) return undefined
685
+ return ctx.get('conversation')?.input?.for?.(actx)
686
+ } catch {
687
+ return undefined
688
+ }
689
+ }
690
+
691
+ /**
692
+ * 草稿里已有 chip 时,新 chip 要追加在它们之后。
693
+ *
694
+ * 宿主公开的 `draft` 是剪贴板文本(每个引用只占一个字符),所以坐标不能直接用
695
+ * `draft.length`;这里按参考实现把引用压缩成 \uFFFC 后再数前缀。
696
+ */
697
+ function expertInsertionSpan(target) {
698
+ const snapshot = target.state.getSnapshot()
699
+ let text = snapshot.draft
700
+ for (const occurrence of [...(snapshot.occurrences ?? [])].sort((a, b) => b.offset - a.offset)) {
701
+ const length = occurrence.length ?? 1
702
+ if (occurrence.offset < 0 || length < 1 || occurrence.offset + length > text.length) continue
703
+ text = text.slice(0, occurrence.offset) + '\uFFFC' + text.slice(occurrence.offset + length)
704
+ }
705
+ let end = 0
706
+ while (text[end] === '\uFFFC') {
707
+ end += 1
708
+ if (/\s/u.test(text[end] ?? '') && text[end] !== '\n') end += 1
709
+ }
710
+ return { start: end, end, draftRev: snapshot.draftRev, detectText: text }
711
+ }
712
+
713
+ /** 插入专家引用 chip(坐标由 {@link expertInsertionSpan} 算出);返回是否成功。 */
714
+ function insertExpertReference(target, reference) {
715
+ try {
716
+ return target.insertReference(reference, expertInsertionSpan(target)) === true
717
+ } catch {
718
+ return false
719
+ }
720
+ }
721
+
722
+ /** 草稿正文(去掉 chip)是否为空——只有在用户还没写正文时才补任务示例。 */
723
+ function expertDraftBodyIsEmpty(target) {
724
+ try {
725
+ const span = expertInsertionSpan(target)
726
+ const text = span.detectText || ''
727
+ return (text.slice(0, span.start) + text.slice(span.end)).replace(/\uFFFC/gu, '').trim() === ''
728
+ } catch {
729
+ return false
730
+ }
731
+ }
732
+
733
+ /**
734
+ * 在 chip 之后补一段任务示例。
735
+ *
736
+ * 用 `insertText`:它存在于输入 facade 上但没有写进公开契约(参考实现同样靠 `insertText?` 兜底)。
737
+ * 宿主没有这个方法就安静跳过——补示例只是省事,不该因此报错。
738
+ */
739
+ function appendExpertTaskExample(target, expert) {
740
+ const example = expertTaskExample(expert)
741
+ if (!example || typeof target.insertText !== 'function') return false
742
+ try {
743
+ const snapshot = target.state.getSnapshot()
744
+ const at = String(snapshot.draft || '').length
745
+ return target.insertText('\n\n' + example, { start: at, end: at, draftRev: snapshot.draftRev }) === true
746
+ } catch {
747
+ return false
748
+ }
749
+ }
750
+
751
+ /** 名册读取(`@` 菜单与工具栏共用)。每次现读,用户一改开关立刻生效。 */
752
+ async function loadExpertRoster() {
753
+ const response = await fetch('/api/waterbuddy/experts')
754
+ const payload = await response.json()
755
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
756
+ return { experts: payload.experts || [], divisions: payload.divisions || [] }
757
+ }
758
+
759
+ /** `@` 菜单的最近一次启用快照:`lexicon` / `codec.clipboardText` 是同步接口,只能读缓存。 */
760
+ let expertMentionCache = []
761
+
762
+ /** chip 的剪贴板投影:参考实现用的是 `@名字 `。 */
763
+ function expertMentionText(slug) {
764
+ const expert = expertMentionCache.find(item => item.slug === slug)
765
+ return expert ? '@' + expert.name + ' ' : '@' + String(slug) + ' '
766
+ }
767
+
768
+ /**
769
+ * 分类 / 状态 / 搜索三个筛选控件。
770
+ *
771
+ * 纯展示:状态由 `ExpertRoster` 持有,这里只回调上去(与参考实现的 category-select 对应)。
772
+ */
773
+ function ExpertCategoryFilters({ divisions, total, division, status, query, onDivision, onStatus, onQuery }) {
774
+ return jsxs('div', { style: { display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', margin: '16px 0' }, children: [
775
+ jsxs('div', { style: { display: 'grid' }, children: [
776
+ jsx('span', { style: EXPERT_STYLE.label, children: '分类' }),
777
+ jsxs('select', {
778
+ value: division,
779
+ onChange: event => onDivision(event.target.value),
780
+ style: { ...EXPERT_STYLE.field, minWidth: 190 },
781
+ children: [
782
+ jsx('option', { value: 'all', children: '全部(' + total + ')' }),
783
+ divisions.map(item => jsx('option', { value: item.slug, children: item.name + '(' + item.count + ')' }, item.slug)),
784
+ ],
785
+ }),
786
+ ] }),
787
+ jsxs('div', { style: { display: 'grid' }, children: [
788
+ jsx('span', { style: EXPERT_STYLE.label, children: '状态' }),
789
+ jsxs('select', {
790
+ value: status,
791
+ onChange: event => onStatus(event.target.value),
792
+ style: { ...EXPERT_STYLE.field, minWidth: 140 },
793
+ children: [
794
+ jsx('option', { value: 'all', children: '全部状态' }),
795
+ jsx('option', { value: 'enabled', children: '已启用' }),
796
+ jsx('option', { value: 'disabled', children: '已停用' }),
797
+ ],
798
+ }),
799
+ ] }),
800
+ jsxs('div', { style: { display: 'grid', flex: 1, minWidth: 220 }, children: [
801
+ jsx('span', { style: EXPERT_STYLE.label, children: '搜索' }),
802
+ jsx('input', {
803
+ name: 'expert-search',
804
+ value: query,
805
+ placeholder: '搜索专家、职责或分类',
806
+ onChange: event => onQuery(event.target.value),
807
+ style: { ...EXPERT_STYLE.field, width: '100%' },
808
+ }),
809
+ ] }),
810
+ ] })
811
+ }
812
+
813
+ /**
814
+ * 任务示例:插好专家 chip 之后,如果用户还没写正文,就补一句"该交代什么"。
815
+ *
816
+ * 按**分类**给(参考实现是按专家逐条人工维护,我们先用分类级,够用且好维护)。
817
+ * 它只用来帮用户把需求说清楚,**不代表给专家额外授权**——不要在这里写具体结论或口径。
818
+ *
819
+ * 生效条件:宿主必须让 `conversation.input.for(actx)` 暴露 `insertText`。**0.1.6-alpha.1 上它没有**
820
+ * ——公开契约 `SessionInput` 只有 `beginCommand` / `insertReference` / `setDraft` 等,
821
+ * `insertText` 属于内部 `InputFacade`。所以当前版本插完 chip 不会补示例(实测确认),
822
+ * 代码保留为前向兼容:宿主哪天暴露了,这段就自动生效,不需要再改调用方。
823
+ */
824
+ const EXPERT_TASK_EXAMPLES = {
825
+ supply: '先说明对象与现状(哪座厂/哪段管网、规模、已掌握的数据),再给排查或处置步骤、判断依据,以及哪些结论需要实测数据才能确认。',
826
+ drainage: '先说明管网类型、管龄与已有记录,再给排查范围与方法、判定标准,并区分"日常养护 / 小修 / 改造"。',
827
+ quality: '先说明水源与工艺、异常指标与出现时间,再给判定依据、处置步骤与观察指标;涉及健康风险请明确上报路径。',
828
+ digital: '先说明业务目标、现有系统与数据来源,再给方案对比(成本 / 风险 / 改动范围),并指出数据由谁维护。',
829
+ policy: '先说明地域与适用层级,再给条文依据与对照结论;记不准的条款请标出需核对原文。',
830
+ }
831
+
832
+ /** 取某位专家的任务示例;没有对应示例时返回 undefined(不补,保持草稿干净)。 */
833
+ function expertTaskExample(expert) {
834
+ return EXPERT_TASK_EXAMPLES[expert && expert.division]
835
+ }
836
+
837
+ // ── expert-discovery.js ──────────────────────────────────────────
838
+
839
+ /**
840
+ * 输入框左侧的专家入口。
841
+ *
842
+ * 选中后写入的是**宿主原生引用 chip**,不是纯文本:chip 是原子的、可整体删除、由宿主
843
+ * 正确序列化——就是内置 `@` 提及的行为。写入走 slot `inject` 注入的 `insertExpert`;
844
+ * 拿不到输入机时回落到把召唤指令写进草稿,绝不静默什么都不做。
845
+ */
846
+ /** 专家菜单宽度;横向夹取与菜单本体共用同一个值。 */
847
+ const MENU_WIDTH = 320
848
+
849
+ function ExpertInputEntry(props) {
850
+ if (props && typeof props.insertExpert === 'function') return ExpertInputButton(props)
851
+ if (props && props.inputActions && typeof props.inputActions.setDraft === 'function') {
852
+ return ExpertInputButton({ ...props, insertExpert: undefined })
853
+ }
854
+ // 两条路都不通就整体不渲染:附加控件不能把输入区搞崩。
855
+ return null
856
+ }
857
+
858
+ /** 真正的按钮:读草稿、拉启用中的专家、写入引用(或回落召唤指令)。 */
859
+ function ExpertInputButton({ inputActions, insertExpert, useInput }) {
860
+ const { useEffect, useRef, useState } = require('react')
861
+ // 只有拿到 useInput 才当作 hook 用;拿不到就用普通函数,钩子顺序因此稳定。
862
+ const readDraft = typeof useInput === 'function' ? useInput : () => undefined
863
+ const draft = readDraft(state => (state && state.draft) || '') || ''
864
+ const [open, setOpen] = useState(false)
865
+ const rootRef = useRef(null)
866
+ // 菜单最大高度按**按钮上方的可用空间**算(参考实现的 resolveExpertMenuPosition 同法):
867
+ // 固定写 320 会让 10 位专家的分组列表必然出现滚动条。
868
+ const [menuMaxHeight, setMenuMaxHeight] = useState(360)
869
+ // 菜单相对按钮的横向偏移。固定 left:0 会让靠右的按钮把菜单顶出视口,整页出现横向滚动条;
870
+ // 参考实现按视口夹取,这里照做。
871
+ const [menuLeft, setMenuLeft] = useState(0)
872
+ const [experts, setExperts] = useState(null)
873
+ const [query, setQuery] = useState('')
874
+ const [notice, setNotice] = useState('')
875
+
876
+ useEffect(() => {
877
+ if (!open) return undefined
878
+ const root = rootRef.current
879
+ if (!root || typeof root.getBoundingClientRect !== 'function') return undefined
880
+ const rect = root.getBoundingClientRect()
881
+ const viewportHeight = (typeof window !== 'undefined' && window.innerHeight) || 800
882
+ const viewportWidth = (typeof window !== 'undefined' && window.innerWidth) || 1200
883
+ // 高度够用即可:既不占满整屏(占满像"弹了个页面"),也不至于只露三项。
884
+ const space = Math.floor(rect.top - 4 - 12)
885
+ setMenuMaxHeight(Math.max(200, Math.min(space, Math.min(420, Math.floor(viewportHeight * 0.55)))))
886
+ // 横向夹取:与参考实现同一算式,保证菜单左右都不越出视口(12px 内边距)。
887
+ setMenuLeft(Math.max(12 - rect.left, Math.min(0, viewportWidth - 12 - MENU_WIDTH - rect.left)))
888
+ return undefined
889
+ }, [open])
890
+
891
+ useEffect(() => {
892
+ // 点击/触摸到菜单与按钮之外就收起(参考实现监听的是 document 的 pointerdown)。
893
+ // 测试替身没有 document.addEventListener,所以先探测再用。
894
+ if (!open || typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
895
+ return undefined
896
+ }
897
+ const onPointerDown = event => {
898
+ const target = event && event.target
899
+ const root = rootRef.current
900
+ if (root && target && typeof root.contains === 'function' && root.contains(target)) return
901
+ setOpen(false)
902
+ }
903
+ document.addEventListener('pointerdown', onPointerDown)
904
+ return () => document.removeEventListener('pointerdown', onPointerDown)
905
+ }, [open])
906
+
907
+ useEffect(() => {
908
+ // 名册只在第一次展开时拉,而且只保留启用中的专家——停用的召唤了也会被拒。
909
+ if (!open || experts !== null) return undefined
910
+ let cancelled = false
911
+ fetch('/api/waterbuddy/experts')
912
+ .then(async response => {
913
+ const payload = await response.json()
914
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
915
+ return payload
916
+ })
917
+ .then(payload => { if (!cancelled) setExperts((payload.experts || []).filter(expert => expert.enabled)) })
918
+ .catch(() => { if (!cancelled) setExperts([]) })
919
+ return () => { cancelled = true }
920
+ }, [open])
921
+
922
+ const pick = expert => {
923
+ if (typeof insertExpert === 'function' && insertExpert(buildExpertReference(expert), expert) === true) {
924
+ setNotice('')
925
+ setOpen(false)
926
+ setQuery('')
927
+ return
928
+ }
929
+ // 兜底:引用写不进去时至少把意图留下。
930
+ if (inputActions && typeof inputActions.setDraft === 'function') {
931
+ const instruction = '召唤「' + expert.name + '」完成:'
932
+ inputActions.setDraft(draft.trim() ? draft.replace(/\s+$/, '') + '\n' + instruction : instruction)
933
+ setOpen(false)
934
+ setQuery('')
935
+ return
936
+ }
937
+ setNotice('无法写入输入框')
938
+ }
939
+
940
+ const needle = query.trim().toLowerCase()
941
+ const shown = (experts || []).filter(expert => !needle
942
+ || expert.name.toLowerCase().includes(needle)
943
+ || expert.description.toLowerCase().includes(needle)
944
+ || String(expert.divisionName || '').toLowerCase().includes(needle))
945
+ // 按分类分组渲染(与参考实现的菜单一致):模型侧的分区就是这里的分组。
946
+ const grouped = []
947
+ for (const expert of shown) {
948
+ const last = grouped[grouped.length - 1]
949
+ if (last && last.slug === expert.division) last.experts.push(expert)
950
+ else grouped.push({ slug: expert.division, name: expert.divisionName || expert.division, experts: [expert] })
951
+ }
952
+
953
+ return jsxs('div', { ref: rootRef, style: { position: 'relative' }, children: [
954
+ jsx('button', {
955
+ type: 'button',
956
+ 'aria-label': '召唤专家',
957
+ 'aria-expanded': open,
958
+ onClick: () => setOpen(value => !value),
959
+ style: {
960
+ display: 'flex', alignItems: 'center', gap: 6, height: 28, padding: '0 10px',
961
+ border: '1px solid ' + (open ? '#2f6fed' : '#dbe3ee'), borderRadius: 8,
962
+ background: open ? '#eef4ff' : '#fff', color: open ? '#2f6fed' : '#475569',
963
+ fontSize: 12.5, cursor: 'pointer',
964
+ },
965
+ children: [
966
+ // 用户提供的图标:人形 + 右上角星芒(1024 视图、面性填充)。
967
+ // 原样内联其 path,不改几何;fill 用 currentColor 以跟随主题与悬停色。
968
+ jsx('svg', {
969
+ viewBox: '0 0 1024 1024',
970
+ width: 15,
971
+ height: 15,
972
+ fill: 'currentColor',
973
+ 'aria-hidden': true,
974
+ children: jsx('path', {
975
+ d: 'M770.08 96.32c1.728 0.64 3.072 1.984 3.712 3.712l38.848 107.584c0.64 1.728 1.984 3.104 3.712 3.712l107.584 38.848a6.144 6.144 0 0 1 0 11.584l-107.584 38.848a6.144 6.144 0 0 0-3.712 3.712l-38.848 107.584a6.144 6.144 0 0 1-11.584 0L723.36 304.32a6.144 6.144 0 0 0-3.712-3.712L612.064 261.76a6.144 6.144 0 0 1 0-11.584l107.584-38.848a6.144 6.144 0 0 0 3.712-3.712l38.848-107.584c1.184-3.2 4.704-4.8 7.872-3.68zM576 160h-192Q264.704 160 180.352 244.352 96 328.704 96 448v192q0 119.296 84.352 203.648Q264.704 928 384 928h256q119.296 0 203.648-84.352Q928 759.296 928 640v-128h-64v128q0 92.8-65.6 158.4Q732.8 864 640 864h-256q-92.8 0-158.4-65.6Q160 732.8 160 640v-192q0-92.8 65.6-158.4Q291.2 224 384 224h192V160z m96 248.224L568.224 512 672 615.776l45.248-45.28L658.752 512l58.496-58.496L672 408.224zM320 608v-160h64v160h-64z',
976
+ }),
977
+ }),
978
+ jsx('span', { children: '召唤专家' }),
979
+ ],
980
+ }),
981
+ open ? jsxs('div', {
982
+ style: {
983
+ // boxSizing: border-box 让 `width` 就是实宽——夹取算式按 MENU_WIDTH 计算,两者必须一致,
984
+ // 否则在窄窗口下菜单仍会比算式宽出一圈内边距并与视口打架。
985
+ boxSizing: 'border-box',
986
+ position: 'absolute', bottom: 34, left: menuLeft, zIndex: 30, width: MENU_WIDTH, maxHeight: menuMaxHeight,
987
+ overflowY: 'auto', padding: 8, border: '1px solid #e2e8f0', borderRadius: 12,
988
+ background: '#fff', boxShadow: '0 12px 32px rgba(15,23,42,.16)', overflowX: 'hidden',
989
+ },
990
+ children: [
991
+ jsx('input', {
992
+ name: 'expert-input-search',
993
+ value: query,
994
+ placeholder: '搜索名称、领域或关键词',
995
+ onChange: event => setQuery(event.target.value),
996
+ style: { boxSizing: 'border-box', width: '100%', height: 30, padding: '0 8px', marginBottom: 8, border: '1px solid #dbe3ee', borderRadius: 8, fontSize: 12.5 },
997
+ }),
998
+ notice ? jsx('div', { style: { padding: '4px 6px', color: '#b91c1c', fontSize: 11.5 }, children: notice }) : null,
999
+ experts === null
1000
+ ? jsx('div', { style: { padding: '10px 6px', color: '#94a3b8', fontSize: 12.5 }, children: '正在加载…' })
1001
+ : shown.length === 0
1002
+ ? jsx('div', { style: { padding: '10px 6px', color: '#94a3b8', fontSize: 12.5 }, children: experts.length === 0 ? '没有启用中的专家' : '没有匹配的专家' })
1003
+ : grouped.map(group => jsxs('div', { key: group.slug, children: [
1004
+ jsx('div', { style: { padding: '6px 8px 4px', color: '#94a3b8', fontSize: 11 }, children: group.name }),
1005
+ group.experts.map(expert => jsxs('button', {
1006
+ type: 'button',
1007
+ key: expert.slug,
1008
+ onClick: () => pick(expert),
1009
+ style: { boxSizing: 'border-box', display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 8px', border: 0, borderRadius: 8, background: 'transparent', textAlign: 'left', cursor: 'pointer' },
1010
+ children: [
1011
+ expert.avatar
1012
+ ? jsx('img', { src: expert.avatar, alt: '', style: { width: 24, height: 24, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 } })
1013
+ : jsx('span', { style: { fontSize: 16, lineHeight: '18px' }, children: expert.emoji || '🧠' }),
1014
+ jsxs('span', { style: { minWidth: 0, display: 'grid', gap: 2 }, children: [
1015
+ jsx('strong', { style: { color: '#162033', fontSize: 12.5 }, children: expert.name }),
1016
+ jsx('small', { style: { color: '#8a94a6', fontSize: 11.5, lineHeight: 1.5 }, children: expert.description }),
1017
+ ] }),
1018
+ ],
1019
+ }, expert.slug)),
1020
+ ] })),
1021
+ ],
1022
+ }) : null,
1023
+ ] })
1024
+ }
1025
+
1026
+ /**
1027
+ * 一个分类的 `@` 触发器源。
1028
+ *
1029
+ * 与参考实现一致:候选只给该分类里**启用中**的专家;选中后插入的是同一个原生引用
1030
+ * chip(所以打字 `@` 和点工具栏按钮得到的 token 完全一样)。`codec` 是宿主用来把
1031
+ * chip 序列化给模型的:提交时逐引用调用,失败会挡住发送,不会静默降级成剪贴板文本。
1032
+ */
1033
+ function createExpertTriggerSource(ctx, division, index) {
1034
+ const inDivision = () => expertMentionCache.filter(expert => expert.division === division.slug)
1035
+ return {
1036
+ trigger: '@',
1037
+ name: EXPERT_SOURCE_ID(division.slug),
1038
+ order: 100 + index,
1039
+ showGroupTitle: false,
1040
+ async candidates(_session, request) {
1041
+ const { experts } = await loadExpertRoster()
1042
+ expertMentionCache = experts.filter(expert => expert.enabled)
1043
+ const needle = String((request && request.query) || '').toLowerCase()
1044
+ return inDivision()
1045
+ .filter(expert => !needle
1046
+ || expert.name.toLowerCase().includes(needle)
1047
+ || expert.description.toLowerCase().includes(needle))
1048
+ .map(expert => ({
1049
+ name: expert.name,
1050
+ hint: expert.slug,
1051
+ section: division.name,
1052
+ // 有头像就用头像当菜单行图标(宿主允许候选带 icon 组件);
1053
+ // 注意:**插入后的 chip 用不了图片** —— ReferenceInsert.appearance 只认
1054
+ // 'session' | 'file' | 'folder' 三个内置字形,那里是宿主的限制。
1055
+ ...(expert.avatar
1056
+ ? { icon: () => jsx('img', { src: expert.avatar, alt: '', style: { width: 16, height: 16, borderRadius: '50%', objectFit: 'cover' } }) }
1057
+ : {}),
1058
+ }))
1059
+ },
1060
+ onPick(pick) {
1061
+ const slug = (pick && pick.candidate && pick.candidate.hint) || ''
1062
+ const expert = expertMentionCache.find(item => item.slug === slug)
1063
+ if (expert === undefined) return undefined
1064
+ const reference = buildExpertReference(expert)
1065
+ const sessionId = pick && pick.session ? pick.session.sessionId : undefined
1066
+ const target = resolveExpertInputTarget(ctx, sessionId)
1067
+ // 拿不到输入机时把引用交给管线自己插(参考实现同样这样回落)。
1068
+ if (target === undefined) return { insert: reference }
1069
+ try {
1070
+ if (target.insertReference(reference, (pick && pick.span) || expertInsertionSpan(target)) !== true) {
1071
+ return undefined
1072
+ }
1073
+ appendExpertTaskExample(target, expert)
1074
+ return 'handled'
1075
+ } catch {
1076
+ return undefined
1077
+ }
1078
+ },
1079
+ /**
1080
+ * 点击历史/草稿里的 chip 时,宿主会逐个源问「这次预览你要不要接管」。
1081
+ * 没有源接受,它就回落到**默认手势:按文件路径打开文档预览** —— 于是把专家名当路径
1082
+ * 去 realpath,报 `ENOENT: …/智慧水务案例专家` 并在右侧弹出一个报错面板。
1083
+ * 这里返回 true 表示我们接管这次手势(不做别的事),把那个回落挡住。
1084
+ */
1085
+ openReference() {
1086
+ return true
1087
+ },
1088
+ lexicon: () => inDivision().map(expert => expert.name),
1089
+ subscribeLexicon: () => () => {},
1090
+ codec: {
1091
+ clipboardText: expertMentionText,
1092
+ async serialize(slug) {
1093
+ const { experts } = await loadExpertRoster()
1094
+ const expert = experts.find(item => item.slug === slug && item.enabled)
1095
+ if (expert === undefined) throw new Error('专家不可用')
1096
+ return '@' + expert.name + ' '
1097
+ },
1098
+ },
1099
+ }
1100
+ }
1101
+
1102
+ /**
1103
+ * 提示词弹窗(只读)。
1104
+ *
1105
+ * 是**真组件**(由 `jsx(ExpertPromptDialog, ...)` 渲染),不是普通函数调用:
1106
+ * 直接函数调用会把它的 hook 并进父组件的 hook 序列,弹窗一开一关就改变 hook 数量,
1107
+ * React 会抛 "Rendered more hooks than during the previous render"。
1108
+ */
1109
+ function ExpertPromptDialog({ dialog, onClose }) {
1110
+ return jsx('div', { style: EXPERT_STYLE.overlay, onClick: event => { if (event.target === event.currentTarget) onClose() }, children:
1111
+ jsxs('div', { style: EXPERT_STYLE.modal, children: [
1112
+ jsxs('div', { style: EXPERT_STYLE.modalHead, children: [
1113
+ jsx('strong', { style: { color: '#0f172a', fontSize: 14 }, children: dialog.expert.name }),
1114
+ jsx('span', { style: { color: '#94a3b8', fontSize: 11.5 }, children: dialog.expert.slug }),
1115
+ jsx('button', { type: 'button', onClick: onClose, style: { ...EXPERT_STYLE.ghost, marginLeft: 'auto' }, children: '关闭' }),
1116
+ ] }),
1117
+ jsx('div', { style: EXPERT_STYLE.modalBody, children:
1118
+ dialog.loading
1119
+ ? jsx('span', { style: { color: '#7b8799', fontSize: 12.5 }, children: '正在加载提示词…' })
1120
+ : jsx('pre', { style: EXPERT_STYLE.pre, children: dialog.body }) }),
1121
+ jsxs('div', { style: EXPERT_STYLE.modalFoot, children: [
1122
+ jsx('button', {
1123
+ type: 'button',
1124
+ onClick: () => { void writeClipboard(dialog.body || '').catch(() => onClose()) },
1125
+ style: EXPERT_STYLE.ghost,
1126
+ children: '复制提示词',
1127
+ }),
1128
+ ] }),
1129
+ ] }) })
1130
+ }
1131
+
1132
+ /**
1133
+ * 自定义专家编辑器(新建 / 编辑)。
1134
+ *
1135
+ * **刻意不用 hook**:表单草稿由 `ExpertRoster` 持有(`form` + `onField`)。
1136
+ * 弹窗若自己 `useState`,它就会被并进父组件的 hook 序列,弹窗一开一关改变 hook 数量,
1137
+ * React 会抛 "Rendered more hooks than during the previous render"。
1138
+ */
1139
+ function ExpertCustomEditor({ dialog, divisions, busy, form, onField, onSave, onDelete, onClose }) {
1140
+ return jsx('div', { style: EXPERT_STYLE.overlay, onClick: event => { if (event.target === event.currentTarget) onClose() }, children:
1141
+ jsxs('div', { style: EXPERT_STYLE.modal, children: [
1142
+ jsxs('div', { style: EXPERT_STYLE.modalHead, children: [
1143
+ jsx('strong', { style: { color: '#0f172a', fontSize: 14 }, children: dialog.mode === 'create' ? '新建专家' : '编辑专家' }),
1144
+ jsx('span', { style: { color: '#94a3b8', fontSize: 11.5 }, children: '' }),
1145
+ jsx('button', { type: 'button', onClick: onClose, style: { ...EXPERT_STYLE.ghost, marginLeft: 'auto' }, children: '关闭' }),
1146
+ ] }),
1147
+ jsx('div', { style: EXPERT_STYLE.modalBody, children:
1148
+ jsxs('div', { style: { display: 'grid', gap: 12 }, children: [
1149
+ jsxs('div', { style: { display: 'grid', gridTemplateColumns: '80px 1fr 200px', gap: 10 }, children: [
1150
+ jsxs('div', { style: { display: 'grid' }, children: [
1151
+ jsx('span', { style: EXPERT_STYLE.label, children: '图标' }),
1152
+ jsx('input', { name: 'expert-emoji', value: form.emoji, onChange: event => onField('emoji', event.target.value), style: EXPERT_STYLE.field }),
1153
+ ] }),
1154
+ jsxs('div', { style: { display: 'grid' }, children: [
1155
+ jsx('span', { style: EXPERT_STYLE.label, children: '名称' }),
1156
+ jsx('input', { name: 'expert-name', value: form.name, onChange: event => onField('name', event.target.value), style: EXPERT_STYLE.field }),
1157
+ ] }),
1158
+ jsxs('div', { style: { display: 'grid' }, children: [
1159
+ jsx('span', { style: EXPERT_STYLE.label, children: '分类' }),
1160
+ jsxs('select', { value: form.division, onChange: event => onField('division', event.target.value), style: EXPERT_STYLE.field, children:
1161
+ divisions.map(item => jsx('option', { value: item.slug, children: item.name }, item.slug)) }),
1162
+ ] }),
1163
+ ] }),
1164
+ jsxs('div', { style: { display: 'grid' }, children: [
1165
+ jsx('span', { style: EXPERT_STYLE.label, children: '头像图片地址(可留空,留空则用上面的图标)' }),
1166
+ jsx('input', { name: 'expert-avatar', value: form.avatar || '', placeholder: 'https://…/avatar.png', onChange: event => onField('avatar', event.target.value), style: EXPERT_STYLE.field }),
1167
+ ] }),
1168
+ jsxs('div', { style: { display: 'grid' }, children: [
1169
+ jsx('span', { style: EXPERT_STYLE.label, children: '职责描述(显示在卡片上)' }),
1170
+ jsx('input', { name: 'expert-description', value: form.description, onChange: event => onField('description', event.target.value), style: EXPERT_STYLE.field }),
1171
+ ] }),
1172
+ jsxs('div', { style: { display: 'grid' }, children: [
1173
+ jsx('span', { style: EXPERT_STYLE.label, children: '提示词正文(作为专家分身的系统提示词)' }),
1174
+ jsx('textarea', {
1175
+ name: 'expert-prompt',
1176
+ value: form.prompt,
1177
+ onChange: event => onField('prompt', event.target.value),
1178
+ style: { ...EXPERT_STYLE.field, height: 260, padding: 10, lineHeight: 1.6, resize: 'vertical', fontFamily: 'inherit' },
1179
+ }),
1180
+ ] }),
1181
+ ] }) }),
1182
+ jsxs('div', { style: EXPERT_STYLE.modalFoot, children: [
1183
+ dialog.mode === 'edit'
1184
+ ? jsx('button', { type: 'button', disabled: busy, onClick: () => { void onDelete() }, style: { ...EXPERT_STYLE.ghost, marginRight: 'auto', color: '#b91c1c' }, children: '删除' })
1185
+ : null,
1186
+ jsx('button', { type: 'button', onClick: onClose, style: EXPERT_STYLE.ghost, children: '取消' }),
1187
+ jsx('button', { type: 'button', disabled: busy, onClick: () => { void onSave() }, style: EXPERT_STYLE.primary, children: '保存' }),
1188
+ ] }),
1189
+ ] }) })
1190
+ }
1191
+
1192
+ // ── roster.js ────────────────────────────────────────────────────
1193
+
1194
+ /** 正文缓存:`slug → persona 正文`。跨渲染存活,供"预取后同步写剪贴板"用。 */
1195
+ const promptCache = new Map()
1196
+
1197
+ /** 取一位专家的 persona 正文(展示用原文)。 */
1198
+ async function fetchExpertBody(slug) {
1199
+ const response = await fetch('/api/waterbuddy/experts?slug=' + encodeURIComponent(slug))
1200
+ const payload = await response.json()
1201
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
1202
+ return payload.body || ''
1203
+ }
1204
+
1205
+ /** 设置里的「专家」页:浏览、启用停用、以及管理自定义专家。
1206
+ *
1207
+ * 三件事刻意分开:
1208
+ * - 名册快照只带元数据,**正文按需拉**(10 份 persona 动辄十几 KB,不能塞进首屏);
1209
+ * - 启用状态是**整体替换的白名单**并带修订号,两个窗口同时改时落后一方会被拒绝;
1210
+ * - 停用只影响可召唤范围,不隐藏卡片——用户在设置里要能看到并重新打开它。
1211
+ */
1212
+ function ExpertRoster() {
1213
+ const { useEffect, useState } = require('react')
1214
+ const [snapshot, setSnapshot] = useState(null)
1215
+ const [error, setError] = useState('')
1216
+ const [busy, setBusy] = useState(false)
1217
+ const [tab, setTab] = useState('all')
1218
+ const [division, setDivision] = useState('all')
1219
+ const [status, setStatus] = useState('all')
1220
+ const [query, setQuery] = useState('')
1221
+ const [dialog, setDialog] = useState(null)
1222
+ // 弹窗的表单草稿由这里持有:弹窗自己不 use hook,才不会改变本组件的 hook 数量。
1223
+ const [form, setForm] = useState(null)
1224
+
1225
+ const load = async () => {
1226
+ try {
1227
+ const response = await fetch('/api/waterbuddy/experts')
1228
+ const payload = await response.json()
1229
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
1230
+ setSnapshot(payload)
1231
+ setError('')
1232
+ } catch (err) {
1233
+ setError(err.message)
1234
+ }
1235
+ }
1236
+
1237
+ useEffect(() => {
1238
+ let cancelled = false
1239
+ fetch('/api/waterbuddy/experts')
1240
+ .then(async response => {
1241
+ const payload = await response.json()
1242
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
1243
+ return payload
1244
+ })
1245
+ .then(payload => { if (!cancelled) setSnapshot(payload) })
1246
+ .catch(err => { if (!cancelled) setError(err.message) })
1247
+ return () => { cancelled = true }
1248
+ }, [])
1249
+
1250
+ /** 所有写操作共用:带上当前修订号,成功后用返回值刷新快照。 */
1251
+ const mutate = async (path, payload) => {
1252
+ setBusy(true)
1253
+ try {
1254
+ const response = await fetch(path, {
1255
+ method: 'POST',
1256
+ headers: { 'content-type': 'application/json' },
1257
+ body: JSON.stringify({ ...payload, expectedRevision: snapshot.revision }),
1258
+ })
1259
+ const body = await response.json()
1260
+ if (!response.ok) throw new Error(body.error || 'save-failed')
1261
+ setSnapshot(current => ({ ...current, ...body }))
1262
+ setError('')
1263
+ return true
1264
+ } catch (err) {
1265
+ setError(err.message)
1266
+ // 修订号冲突(别的窗口刚改过)时主动重拉一次:让用户直接看到最新状态并重试,
1267
+ // 而不是停在一份永远写不进去的陈旧快照上。
1268
+ if (/REVISION_CONFLICT/.test(err.message)) await load()
1269
+ return false
1270
+ } finally {
1271
+ setBusy(false)
1272
+ }
1273
+ }
1274
+
1275
+ const toggle = expert => {
1276
+ const next = (snapshot.experts || [])
1277
+ .filter(item => (item.slug === expert.slug ? !expert.enabled : item.enabled))
1278
+ .map(item => item.slug)
1279
+ return mutate('/api/waterbuddy/experts/enabled', { enabled: next })
1280
+ }
1281
+
1282
+ const openPrompt = async expert => {
1283
+ setDialog({ mode: 'view', expert, body: '', loading: true })
1284
+ try {
1285
+ const response = await fetch('/api/waterbuddy/experts?slug=' + encodeURIComponent(expert.slug))
1286
+ const payload = await response.json()
1287
+ if (!response.ok) throw new Error(payload.error || 'load-failed')
1288
+ setDialog({ mode: 'view', expert, body: payload.body || '', loading: false })
1289
+ } catch (err) {
1290
+ setDialog(null)
1291
+ setError(err.message)
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * 复制提示词。
1297
+ *
1298
+ * 关键在时序:WebKit(Tauri 用的就是它)要求剪贴板写入发生在**用户手势的同步调用栈**里,
1299
+ * 先 `await fetch` 再写就会被拒(`NotAllowedError`),连 execCommand 兜底也一起失效——
1300
+ * 这正是"复制提示词不起效果"的原因。所以:悬停/聚焦时先把正文预取进缓存,点击时
1301
+ * 缓存命中就是同步发起写入,手势不丢;没命中才走异步路径(并在失败时提示再点一次)。
1302
+ */
1303
+ const copyPrompt = async expert => {
1304
+ const cached = promptCache.get(expert.slug)
1305
+ if (cached !== undefined) {
1306
+ try {
1307
+ await writeClipboard(cached)
1308
+ setError('')
1309
+ } catch (err) {
1310
+ setError('复制失败:' + err.message)
1311
+ }
1312
+ return
1313
+ }
1314
+ try {
1315
+ const body = await fetchExpertBody(expert.slug)
1316
+ promptCache.set(expert.slug, body)
1317
+ await writeClipboard(body)
1318
+ setError('')
1319
+ } catch (err) {
1320
+ setError('复制失败:' + err.message + '(正文已缓存,再点一次即可)')
1321
+ }
1322
+ }
1323
+
1324
+ /** 悬停/聚焦时预取正文,让点击那一刻能同步写入剪贴板。 */
1325
+ const prefetchPrompt = expert => {
1326
+ if (promptCache.has(expert.slug)) return
1327
+ void fetchExpertBody(expert.slug)
1328
+ .then(body => { promptCache.set(expert.slug, body) })
1329
+ .catch(() => {})
1330
+ }
1331
+
1332
+ const experts = snapshot ? snapshot.experts || [] : []
1333
+ const divisions = snapshot ? snapshot.divisions || [] : []
1334
+
1335
+ /** 打开新建/编辑弹窗:初始化表单草稿。 */
1336
+ const openEditor = (mode, expert) => {
1337
+ setForm({
1338
+ slug: expert.slug || '',
1339
+ name: expert.name || '',
1340
+ description: expert.description || '',
1341
+ division: expert.division || (divisions[0] ? divisions[0].slug : 'supply'),
1342
+ emoji: expert.emoji || '🧠',
1343
+ avatar: expert.avatar || '',
1344
+ prompt: expert.prompt || '',
1345
+ })
1346
+ setDialog({ mode, expert })
1347
+ }
1348
+ const onField = (key, value) => setForm(current => ({ ...current, [key]: value }))
1349
+ const needle = query.trim().toLowerCase()
1350
+ const shown = experts.filter(expert => {
1351
+ if (tab === 'custom' && !expert.custom) return false
1352
+ if (tab === 'builtin' && expert.custom) return false
1353
+ if (division !== 'all' && expert.division !== division) return false
1354
+ if (status === 'enabled' && !expert.enabled) return false
1355
+ if (status === 'disabled' && expert.enabled) return false
1356
+ if (!needle) return true
1357
+ return (
1358
+ expert.name.toLowerCase().includes(needle)
1359
+ || expert.description.toLowerCase().includes(needle)
1360
+ || expert.slug.toLowerCase().includes(needle)
1361
+ || expert.divisionName.toLowerCase().includes(needle)
1362
+ )
1363
+ })
1364
+
1365
+ if (snapshot === null && !error) {
1366
+ return jsx('div', { style: { color: '#7b8799', fontSize: 13 }, children: '正在加载专家名册…' })
1367
+ }
1368
+
1369
+ const header = jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 4 }, children: [
1370
+ jsx('h2', { style: { margin: 0, color: '#0f172a', fontSize: 19, fontWeight: 600 }, children: '专家' }),
1371
+ jsx('span', { style: { padding: '2px 8px', borderRadius: 6, background: '#f1f5f9', color: '#64748b', fontSize: 11.5 }, children: 'v' + (snapshot && snapshot.version ? snapshot.version : '') }),
1372
+ jsx('span', { style: { color: '#8a94a6', fontSize: 12.5 }, children: experts.length + ' 位专家 · 已启用 ' + (snapshot ? snapshot.enabledCount : 0) }),
1373
+ jsxs('span', { style: { marginLeft: 'auto', display: 'flex', gap: 8 }, children: [
1374
+ jsx('button', {
1375
+ type: 'button',
1376
+ disabled: busy || snapshot?.settingsAvailable === false,
1377
+ onClick: () => openEditor('create', {}),
1378
+ style: EXPERT_STYLE.primary,
1379
+ children: '新建专家',
1380
+ }),
1381
+ jsx('button', { type: 'button', onClick: () => { void load() }, style: EXPERT_STYLE.ghost, children: '刷新' }),
1382
+ ] }),
1383
+ ] })
1384
+
1385
+ const filters = ExpertCategoryFilters({
1386
+ divisions,
1387
+ total: experts.length,
1388
+ division,
1389
+ status,
1390
+ query,
1391
+ onDivision: setDivision,
1392
+ onStatus: setStatus,
1393
+ onQuery: setQuery,
1394
+ })
1395
+
1396
+ const card = expert => jsxs('div', { style: EXPERT_STYLE.card, children: [
1397
+ jsxs('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 10 }, children: [
1398
+ expert.avatar
1399
+ ? jsx('img', { src: expert.avatar, alt: '', style: { width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 } })
1400
+ : jsx('span', { style: { fontSize: 22, lineHeight: '24px' }, children: expert.emoji || '🧠' }),
1401
+ jsxs('div', { style: { minWidth: 0, display: 'grid', gap: 3, flex: 1 }, children: [
1402
+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, children: [
1403
+ jsx('strong', { style: { color: '#0f172a', fontSize: 13.5 }, children: expert.name }),
1404
+ jsx('span', { style: { padding: '1px 7px', borderRadius: 6, background: expert.custom ? '#fef3c7' : '#eef2f8', color: expert.custom ? '#92400e' : '#64748b', fontSize: 11 }, children: expert.custom ? '自定义' : '内置' }),
1405
+ jsx('span', { style: { color: '#94a3b8', fontSize: 11.5 }, children: expert.divisionName }),
1406
+ ] }),
1407
+ jsx('span', { style: { color: '#7b8799', fontSize: 12, lineHeight: 1.6 }, children: expert.description }),
1408
+ ] }),
1409
+ jsx('button', {
1410
+ type: 'button',
1411
+ 'aria-label': (expert.enabled ? '停用 ' : '启用 ') + expert.name,
1412
+ 'aria-pressed': expert.enabled,
1413
+ disabled: busy,
1414
+ onClick: () => { void toggle(expert) },
1415
+ style: { flexShrink: 0, width: 42, height: 24, padding: 0, border: 0, borderRadius: 12, background: expert.enabled ? '#22c55e' : '#cbd5e1', cursor: 'pointer', position: 'relative' },
1416
+ children: jsx('span', { style: { position: 'absolute', top: 3, left: expert.enabled ? 21 : 3, width: 18, height: 18, borderRadius: '50%', background: '#fff', transition: 'left .15s' } }),
1417
+ }),
1418
+ ] }),
1419
+ jsxs('div', { style: { display: 'flex', gap: 8, borderTop: '1px solid #eef2f8', paddingTop: 10, marginTop: 'auto' }, children: [
1420
+ jsx('button', { type: 'button', onClick: () => { void openPrompt(expert) }, style: EXPERT_STYLE.ghost, children: '查看提示词' }),
1421
+ jsx('button', {
1422
+ type: 'button',
1423
+ onClick: () => { void copyPrompt(expert) },
1424
+ // 悬停/聚焦就预取:点击时缓存已就绪,剪贴板写入能落在手势的同步栈里。
1425
+ onMouseEnter: () => prefetchPrompt(expert),
1426
+ onFocus: () => prefetchPrompt(expert),
1427
+ style: EXPERT_STYLE.ghost,
1428
+ children: '复制提示词',
1429
+ }),
1430
+ expert.custom
1431
+ ? jsx('button', { type: 'button', onClick: () => openEditor('edit', expert), style: { ...EXPERT_STYLE.ghost, marginLeft: 'auto' }, children: '编辑' })
1432
+ : null,
1433
+ ] }),
1434
+ ] })
1435
+
1436
+ return jsxs(Fragment, { children: [
1437
+ header,
1438
+ jsxs('div', { style: { display: 'flex', gap: 8, marginTop: 14 }, children: [
1439
+ jsx('button', { type: 'button', onClick: () => setTab('all'), style: EXPERT_STYLE.tab(tab === 'all'), children: '全部' }),
1440
+ jsx('button', { type: 'button', onClick: () => setTab('builtin'), style: EXPERT_STYLE.tab(tab === 'builtin'), children: '内置' }),
1441
+ jsx('button', { type: 'button', onClick: () => setTab('custom'), style: EXPERT_STYLE.tab(tab === 'custom'), children: '自定义' }),
1442
+ ] }),
1443
+ filters,
1444
+ error ? jsx('div', { style: { marginBottom: 12, padding: '8px 10px', borderRadius: 10, background: '#fef2f2', color: '#b91c1c', fontSize: 12.5 }, children: error }) : null,
1445
+ shown.length === 0
1446
+ ? jsx('div', { style: { padding: '28px 0', color: '#94a3b8', fontSize: 13, textAlign: 'center' }, children: '没有匹配的专家' })
1447
+ : jsx('div', { style: { display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }, children: shown.map(expert => jsx('div', { key: expert.slug, children: card(expert)})) }),
1448
+ dialog ? ExpertDialog({ dialog, divisions, busy, form, onField, setDialog, mutate }) : null,
1449
+ ] })
1450
+ }
1451
+
1452
+ /**
1453
+ * 弹窗分发:按 `dialog.mode` 选择提示词弹窗或自定义专家编辑器。
1454
+ *
1455
+ * 这里用**普通函数调用**而不是组件:两个弹窗都不使用 hook(表单草稿在 `ExpertRoster`),
1456
+ * 所以调用它们不会改变本组件的 hook 数量,真实 React 与测试替身的行为一致。
1457
+ */
1458
+ function ExpertDialog({ dialog, divisions, busy, form, onField, setDialog, mutate }) {
1459
+ const close = () => setDialog(null)
1460
+ if (dialog.mode === 'view') {
1461
+ return ExpertPromptDialog({ dialog, onClose: close })
1462
+ }
1463
+ return ExpertCustomEditor({
1464
+ dialog,
1465
+ divisions,
1466
+ busy,
1467
+ form,
1468
+ onField,
1469
+ onClose: close,
1470
+ onSave: async () => {
1471
+ if (await mutate('/api/waterbuddy/experts/custom', { expert: form })) close()
1472
+ },
1473
+ onDelete: async () => {
1474
+ if (await mutate('/api/waterbuddy/experts/custom/delete', { slug: dialog.expert.slug })) close()
1475
+ },
1476
+ })
1477
+ }
1478
+
1479
+ // `inputTriggers` 必须**声明**才能拿到:`ctx.get` 同样受 inject 边界限制(实测未声明时
1480
+ // 拿到 undefined,@ 菜单静默不注册)。参考实现同样把它列在 inject 里。
1481
+ const inject = ['slots', 'inputTriggers']
588
1482
  function apply(ctx) {
589
1483
  ctx.effect(() => {
590
1484
  const normalizeTitle = () => {
@@ -623,6 +1517,114 @@ window.__ModuleLoader__.load({
623
1517
  }))
624
1518
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'waterbuddy-user', order: 90, label: '登录 WaterBuddy' }, UserAction))
625
1519
  ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ name: 'settings.onboarding', id: 'welcome-notice', priority: -1000, order: -100 }, SkipWelcomeNotice))
1520
+ // 专家名册页:在设置左侧列出一项「专家」。`label` 用纯字符串——本插件的界面文案
1521
+ // 都是硬编码中文,没有走宿主词典(见 AGENTS.md 的产物面边界)。
1522
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1523
+ name: 'settings.section',
1524
+ id: 'waterbuddy-experts',
1525
+ order: 95,
1526
+ label: '专家',
1527
+ }, ExpertRoster))
1528
+ // 新会话 hero:标记换成我们的(宿主默认是鸟形图标)。
1529
+ ctx.slots.inject('conversation.hero.brand.mark', () =>
1530
+ ctx.slots.register({ name: 'conversation.hero.brand.mark', priority: -10 }, WaterBuddyHeroMark))
1531
+
1532
+ // hero 标题换成我们的话术。
1533
+ //
1534
+ // 不能用 `ctx.locale.register('conversation', ...)` 覆盖:该服务对已存在的
1535
+ // (namespace, locale) 直接抛错(`already has locale`),插件覆盖不了宿主词条。
1536
+ // 所以在 DOM 层替换这两个已知默认标题(中/英);文案一变就自动不再匹配,
1537
+ // 不会误改别的东西。标题归一化那个观察器挂在 document.head 上,这里只动 body,
1538
+ // 两者互不干涉。
1539
+ ctx.effect(() => {
1540
+ if (typeof document === 'undefined' || typeof MutationObserver !== 'function') return undefined
1541
+ const DEFAULTS = new Set(['探索未至之境', 'Into the Unknown'])
1542
+ const HEADLINE = '我们要做什么?'
1543
+ // NodeFilter.SHOW_TEXT 依规范就是 4;用数值以免依赖测试环境里没有的 NodeFilter 全局。
1544
+ const SHOW_TEXT = 4
1545
+ const swap = root => {
1546
+ if (!root || typeof document.createTreeWalker !== 'function') return
1547
+ const walker = document.createTreeWalker(root, SHOW_TEXT)
1548
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
1549
+ const text = node.nodeValue || ''
1550
+ if (DEFAULTS.has(text.trim())) node.nodeValue = text.replace(text.trim(), HEADLINE)
1551
+ }
1552
+ }
1553
+ swap(document.body)
1554
+ const observer = new MutationObserver(() => swap(document.body))
1555
+ observer.observe(document.body, { childList: true, subtree: true })
1556
+ return () => observer.disconnect()
1557
+ }, 'waterbuddy: hero headline')
1558
+
1559
+ // 会话输入框左侧的专家入口。`inject` 是宿主支持的 slot owner 注入:框架按会话调用它,
1560
+ // 把返回值并进组件 props——我们的实现靠它拿到当前会话的输入机,才能写原生引用 chip。
1561
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
1562
+ name: 'conversation.input.left',
1563
+ id: 'waterbuddy-experts',
1564
+ order: 90,
1565
+ label: '召唤专家',
1566
+ inject: sessionId => ({
1567
+ /**
1568
+ * 插入专家引用 chip;草稿还没写正文时再补一段任务示例(与参考实现一致)。
1569
+ * 补示例失败不影响插入结果——chip 已经进去了。
1570
+ */
1571
+ insertExpert: (reference, expert) => {
1572
+ const target = resolveExpertInputTarget(ctx, sessionId)
1573
+ if (target === undefined) return false
1574
+ if (!insertExpertReference(target, reference)) return false
1575
+ if (expertDraftBodyIsEmpty(target)) appendExpertTaskExample(target, expert)
1576
+ return true
1577
+ },
1578
+ }),
1579
+ }, ExpertInputEntry))
1580
+
1581
+ // 每个分类注册一个 `@` 触发器源:打字 @ 直接列专家,选中的 token 与工具栏按钮一致。
1582
+ // 用 `ctx.get` 软取服务:服务缺失只让这个菜单消失,绝不让整个插件加载失败。
1583
+ ctx.effect(() => {
1584
+ const inputTriggers = typeof ctx.get === 'function' ? ctx.get('inputTriggers') : undefined
1585
+ if (!inputTriggers || typeof inputTriggers.registerSource !== 'function') {
1586
+ console.warn('[WaterBuddy] expert @ menu disabled: host exposes no inputTriggers service')
1587
+ return undefined
1588
+ }
1589
+ let disposers = []
1590
+ let cancelled = false
1591
+ // 专家名集合:给下面的「历史 chip 点击守卫」用(同一次名册请求)。
1592
+ let expertNames = new Set()
1593
+ loadExpertRoster()
1594
+ .then(({ divisions, experts }) => {
1595
+ if (cancelled) return
1596
+ expertNames = new Set(experts.map(expert => expert.name))
1597
+ disposers = divisions.map((division, index) => inputTriggers.registerSource(createExpertTriggerSource(ctx, division, index)))
1598
+ })
1599
+ .catch(error => console.warn('[WaterBuddy] expert @ menu disabled:', error.message))
1600
+
1601
+ // 历史消息里的 `@专家名` 会被宿主的**消息渲染器**渲染成「文件提及」
1602
+ // (`<button data-ref-chip="file" title="@专家名">`),点它就按文件路径打开预览,
1603
+ // 于是报 `ENOENT: <工作区>/专家名`。那条路径不经过输入触发器源,`openReference` 拦不到,
1604
+ // 所以只能在捕获阶段把命中我们专家名的那些点击接管掉。
1605
+ let removeGuard
1606
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1607
+ const onClick = event => {
1608
+ const target = event.target
1609
+ const button = target && typeof target.closest === 'function'
1610
+ ? target.closest('button[data-ref-chip="file"]')
1611
+ : null
1612
+ if (!button) return
1613
+ const name = String(button.getAttribute('title') || '').replace(/^@/, '').trim()
1614
+ if (!name || !expertNames.has(name)) return
1615
+ event.preventDefault()
1616
+ event.stopPropagation()
1617
+ }
1618
+ document.addEventListener('click', onClick, true)
1619
+ removeGuard = () => document.removeEventListener('click', onClick, true)
1620
+ }
1621
+
1622
+ return () => {
1623
+ cancelled = true
1624
+ if (removeGuard) removeGuard()
1625
+ for (const dispose of disposers) dispose()
1626
+ }
1627
+ }, 'waterbuddy: expert @ menu')
626
1628
  }
627
1629
 
628
1630
  exports.inject = inject