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