import * as React from 'react'; import { createPortal } from 'react-dom'; import { CONTEXT_ENHANCEMENT_FIELDS, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH, CONTEXT_GUIDANCE_EXAMPLE, normalizeContextEnhancementConfig, validateContextEnhancementConfig, } from '../../src/channels/shared/context-enhancement.mjs'; import { h, localizeText } from './i18n.js'; const FIELD_LABELS = Object.freeze({ channel: '渠道', conversationType: '会话类型', senderId: '发送者标识', senderName: '发送者昵称', botId: '机器人标识', }); export function contextEnhancementLabel(config) { const { groupEnabled, directEnabled } = normalizeContextEnhancementConfig(config); if (groupEnabled && directEnabled) return '群聊和私聊'; if (groupEnabled) return '仅群聊'; if (directEnabled) return '仅私聊'; return '未开启'; } function ContextIcon({ kind = 'sliders' }) { const path = kind === 'close' ? 'M6 6l12 12M6 18 18 6' : kind === 'chevron' ? 'm9 5 7 7-7 7' : 'M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-3M14 20H3M14 2v4M8 10v4M18 18v4'; return h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', focusable: 'false', }, h('path', { d: path })); } function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, onClose, returnFocusRef, id }) { // A mounted dialog owns its draft; status refreshes must not replace unsaved edits. const [draft, setDraft] = React.useState(() => ({ ...normalizeContextEnhancementConfig(config), ...(!groupSupported ? { groupEnabled: false } : {}), })); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const savingRef = React.useRef(false); const dialogRef = React.useRef(null); const mountedRef = React.useRef(true); const titleId = React.useId(); const descriptionId = React.useId(); const groupNoticeId = React.useId(); const guidanceId = React.useId(); const guidanceHelpId = React.useId(); const fieldIdPrefix = React.useId(); const fieldsHelpId = React.useId(); const senderNameHelpId = React.useId(); const guidanceExample = localizeText(CONTEXT_GUIDANCE_EXAMPLE); const busy = disabled || saving; React.useEffect(() => { mountedRef.current = true; dialogRef.current?.focus?.(); const keepFocus = (event) => { if (dialogRef.current && !dialogRef.current.contains(event.target)) dialogRef.current.focus(); }; globalThis.document?.addEventListener?.('focusin', keepFocus); return () => { mountedRef.current = false; globalThis.document?.removeEventListener?.('focusin', keepFocus); queueMicrotask(() => returnFocusRef.current?.focus?.()); }; }, [returnFocusRef]); const change = (key, value) => { if (busy || savingRef.current) return; setDraft((current) => ({ ...current, [key]: value })); setError(null); }; const cancel = () => { if (!savingRef.current) onClose(); }; const save = async () => { if (busy || savingRef.current) return; savingRef.current = true; setSaving(true); setError(null); dialogRef.current?.focus?.(); try { const next = validateContextEnhancementConfig(draft); await onSave(next); if (mountedRef.current) onClose(); } catch (cause) { if (mountedRef.current) setError(cause?.message ?? '上下文增强保存失败,请重试。'); } finally { savingRef.current = false; if (mountedRef.current) setSaving(false); } }; const content = h('div', { className: 'dim-contextBackdrop', onMouseDown: (event) => { if (event.target === event.currentTarget) cancel(); }, }, h('section', { id, ref: dialogRef, className: 'dim-contextDialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId, 'aria-describedby': descriptionId, 'aria-busy': saving, tabIndex: -1, onKeyDown: (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancel(); } if (event.key !== 'Tab') return; const controls = dialogRef.current?.querySelectorAll?.( 'button:not(:disabled), input:not(:disabled), textarea:not(:disabled)', ); if (!controls?.length) { event.preventDefault(); dialogRef.current?.focus?.(); return; } const first = controls[0]; const last = controls[controls.length - 1]; const active = globalThis.document?.activeElement; if (event.shiftKey && (active === first || active === dialogRef.current)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (active === last || active === dialogRef.current)) { event.preventDefault(); first.focus(); } }, }, h('header', { className: 'dim-contextHeader' }, h('div', { className: 'dim-contextHeaderTitle' }, h('h3', { id: titleId }, '上下文增强'), h('span', { className: 'dim-contextHelp dim-contextHeaderHelp' }, h('button', { type: 'button', className: 'dim-contextHelpButton', disabled: busy, 'aria-label': '查看上下文增强说明', 'aria-describedby': descriptionId, }, h('span', { 'aria-hidden': 'true' }, '?')), h('span', { id: descriptionId, className: 'dim-contextTooltip dim-contextHeaderTooltip', role: 'tooltip' }, '选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。'))), h('button', { type: 'button', className: 'dim-contextClose', 'aria-label': '关闭弹窗', disabled: saving, onClick: cancel, }, h(ContextIcon, { kind: 'close' }))), h('fieldset', { className: 'dim-contextSection', disabled: busy }, h('legend', null, '启用范围'), h('div', { className: 'dim-contextSwitches' }, h('label', { className: 'dim-contextSwitchRow' }, h('span', { className: 'dim-contextSwitchLabel' }, h('span', null, '群聊中启用'), !groupSupported ? h('span', { id: groupNoticeId, className: 'dim-contextUnavailable', }, '(当前渠道不支持群聊)') : null), h('input', { type: 'checkbox', role: 'switch', className: 'dim-contextSwitch', checked: groupSupported && draft.groupEnabled, disabled: busy || !groupSupported, 'aria-describedby': !groupSupported ? groupNoticeId : undefined, onChange: (event) => { if (groupSupported) change('groupEnabled', event.target.checked); }, })), h('label', { className: 'dim-contextSwitchRow' }, h('span', null, '私聊中启用'), h('input', { type: 'checkbox', role: 'switch', className: 'dim-contextSwitch', checked: draft.directEnabled, disabled: busy, onChange: (event) => change('directEnabled', event.target.checked), }))), ), h('fieldset', { className: 'dim-contextSection', disabled: busy }, h('legend', { className: 'dim-contextLegend' }, h('span', null, '来源字段'), h('span', { className: 'dim-contextHelp dim-contextLegendHelp' }, h('button', { type: 'button', className: 'dim-contextHelpButton', disabled: busy, 'aria-label': '查看来源字段说明', 'aria-describedby': fieldsHelpId, }, h('span', { 'aria-hidden': 'true' }, '?')), h('span', { id: fieldsHelpId, className: 'dim-contextTooltip dim-contextLegendTooltip', role: 'tooltip' }, '增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。'))), h('div', { className: 'dim-contextFields' }, CONTEXT_ENHANCEMENT_FIELDS.map((field) => { const fieldId = `${fieldIdPrefix}-${field}`; return h('div', { key: field, className: 'dim-contextField' }, h('input', { id: fieldId, type: 'checkbox', name: field, checked: draft.fields.includes(field), disabled: busy, onChange: (event) => change('fields', event.target.checked ? [...draft.fields, field] : draft.fields.filter((value) => value !== field)), }), h('span', { className: 'dim-contextFieldText' }, h('label', { className: 'dim-contextFieldName', htmlFor: fieldId }, FIELD_LABELS[field]), field === 'senderName' ? h('span', { className: 'dim-contextHelp dim-contextFieldHelp' }, h('button', { type: 'button', className: 'dim-contextHelpButton dim-contextFieldHelpButton', disabled: busy, 'aria-label': '查看发送者昵称字段说明', 'aria-describedby': senderNameHelpId, }, h('span', { 'aria-hidden': 'true' }, '?')), h('span', { id: senderNameHelpId, className: 'dim-contextTooltip dim-contextFieldTooltip', role: 'tooltip', }, '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段, 中也会省略 senderName。')) : null, h('label', { className: 'dim-contextFieldKey', htmlFor: fieldId }, field))); }))), h('div', { className: 'dim-contextGuidance' }, h('div', { className: 'dim-contextEditorHeader' }, h('span', { className: 'dim-contextEditorTitle' }, h('label', { htmlFor: guidanceId }, '增强提示词'), h('span', { className: 'dim-contextHelp' }, h('button', { type: 'button', className: 'dim-contextHelpButton', disabled: busy, 'aria-label': '查看增强提示词使用说明', 'aria-describedby': guidanceHelpId, }, h('span', { 'aria-hidden': 'true' }, '?')), h('span', { id: guidanceHelpId, className: 'dim-contextTooltip dim-contextGuidanceTooltip', role: 'tooltip' }, h('strong', null, '使用说明'), h('span', null, '用于告诉模型如何使用 中已选择的来源字段。只填写正文,插件会自动添加 成对标签。'), h('strong', null, '生效规则'), h('span', null, '只需填写正文,插件自动添加 成对标签。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。'), h('strong', null, '隐私提示'), h('span', null, '发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。'), h('strong', null, '使用示例'), h('span', { className: 'dim-contextTooltipExample' }, guidanceExample)))), h('div', { className: 'dim-contextTextActions' }, h('button', { type: 'button', disabled: busy, onClick: () => change('guidance', guidanceExample) }, '填入示例'), h('button', { type: 'button', disabled: busy, onClick: () => change('guidance', '') }, '清空'))), h('textarea', { id: guidanceId, value: draft.guidance, placeholder: guidanceExample, rows: 4, disabled: busy, maxLength: CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH, 'aria-describedby': guidanceHelpId, onChange: (event) => change('guidance', event.target.value), })), error ? h('p', { className: 'dim-contextError', role: 'alert' }, error) : null, h('footer', { className: 'dim-contextFooter' }, h('button', { type: 'button', disabled: saving, onClick: cancel }, '取消'), h('button', { type: 'button', className: 'dim-contextSave', disabled: busy, onClick: () => { void save(); }, }, saving ? '保存中…' : '保存')))); return globalThis.document?.body ? createPortal(content, document.body) : content; } export function ContextEnhancementEditor({ config, groupSupported = true, disabled = false, onSave }) { const [open, setOpen] = React.useState(false); const entryRef = React.useRef(null); const dialogId = React.useId(); const statusId = React.useId(); const saved = normalizeContextEnhancementConfig(config); const label = contextEnhancementLabel(groupSupported ? saved : { ...saved, groupEnabled: false }); return h(React.Fragment, null, h('button', { type: 'button', ref: entryRef, className: 'dim-contextEntry', disabled, 'aria-label': '上下文增强', 'aria-describedby': statusId, 'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-controls': open ? dialogId : undefined, onClick: () => setOpen(true), }, h(ContextIcon), h('span', { className: 'dim-contextLabel' }, '上下文增强'), h('span', { id: statusId, className: 'dim-contextStatus', 'data-active': label !== '未开启', 'aria-live': 'polite' }, label), h(ContextIcon, { kind: 'chevron' })), open ? h(ContextEnhancementDialog, { id: dialogId, config, groupSupported, disabled, onSave, onClose: () => setOpen(false), returnFocusRef: entryRef, }) : null); }