feat: add message source context enhancement

This commit is contained in:
xmanrui 2026-08-29 10:46:06 +08:00
parent 5928b33057
commit 0cb79f74e9
69 changed files with 6217 additions and 597 deletions

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
@ -13,6 +14,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -162,6 +164,7 @@ function normalizeBot(value) {
configured: value.configured !== false,
workspace: optionalString(value.workspace, 4_096) ?? '',
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',

View file

@ -3,6 +3,7 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -220,6 +221,7 @@ export function AccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -254,6 +256,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' },
@ -294,6 +301,7 @@ function AccountList(props) {
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
onContextEnhancementSave: (config) => props.onContextEnhancementSave(account, config),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -737,13 +745,13 @@ export function DingtalkSettingsTab({ rpcCall }) {
}
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'preset');
setBotBusy(account.botId, operation);
try {
const snapshot = normalizeSnapshot(await invoke(
DINGTALK_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
endpoint,
{ botId: account.botId, ...payload },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
@ -853,7 +861,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onAgentPresetSave: (account, agentPreset) => saveBotSetting(
account, 'preset', DINGTALK_ENDPOINTS.setAgentPreset, { agentPreset },
),
onContextEnhancementSave: (account, config) => saveBotSetting(
account, 'context-enhancement', DINGTALK_ENDPOINTS.setContextEnhancement, { config },
),
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -8,6 +8,7 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
import { normalizeLastMessageError } from "../../last-message-error.js";
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
export const FEISHU_RPC_CHANNEL = "/feishu";
@ -24,6 +25,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set",
setAgentPreset: "bot.preset.set",
setContextEnhancement: "bot.context-enhancement.set",
setGroupResponseMode: "bot.group-response-mode.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
@ -203,6 +205,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
configured: value.configured !== false,
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
bot: normalizeBot(value.bot),

View file

@ -16,6 +16,7 @@ import {
} from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { WorkspaceEditor } from "../../workspace-editor.js";
import { ContextEnhancementEditor } from "../../context-enhancement.js";
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -553,6 +554,7 @@ export function BotCard({
onRepairCallback,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onGroupResponseModeSave,
onGroupMessagePermissionAuthorize,
onRequestRemove,
@ -611,6 +613,11 @@ export function BotCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: connection.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h(GroupResponseModeEditor, {
value: connection.groupResponseMode,
permissionGranted: connection.groupMessagePermissionGranted,
@ -708,6 +715,7 @@ function BotList(props) {
onRepairCallback: () => props.onRepairCallback(bot),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
onGroupMessagePermissionAuthorize: () => props.onGroupMessagePermissionAuthorize(bot),
onRequestRemove: () => props.onRequestRemove(bot),
@ -1296,15 +1304,15 @@ export function FeishuSettingsTab({ rpcCall }) {
}
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const saveAgentPreset = React.useCallback(async (connection, agentPreset) => {
const saveBotSetting = React.useCallback(async (connection, operation, endpoint, payload) => {
const { botId } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "preset");
setBotBusy(botId, operation);
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(
FEISHU_ENDPOINTS.setAgentPreset,
{ botId, agentPreset },
endpoint,
{ botId, ...payload },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
@ -1521,7 +1529,12 @@ export function FeishuSettingsTab({ rpcCall }) {
onReconnect: (bot) => void reconnectOneBot(bot),
onRepairCallback: repairCallback,
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onAgentPresetSave: (connection, agentPreset) => saveBotSetting(
connection, "preset", FEISHU_ENDPOINTS.setAgentPreset, { agentPreset },
),
onContextEnhancementSave: (connection, config) => saveBotSetting(
connection, "context-enhancement", FEISHU_ENDPOINTS.setContextEnhancement, { config },
),
onGroupResponseModeSave: saveGroupResponseMode,
onGroupMessagePermissionAuthorize: authorizeGroupMessages,
onRequestRemove: requestRemove,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const QQ_RPC_CHANNEL = '/qq';
@ -13,6 +14,7 @@ export const QQ_ENDPOINTS = Object.freeze({
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -87,6 +89,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -4,6 +4,7 @@ import { QqLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -166,6 +167,7 @@ export function AccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -198,6 +200,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' },
@ -468,6 +475,12 @@ export function QqSettingsTab({ rpcCall }) {
QQ_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onContextEnhancementSave: (config) => botAction(
account,
'context-enhancement',
QQ_ENDPOINTS.setContextEnhancement,
{ botId: account.botId, config },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -29,6 +30,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
export function createTokenChannelApi(channel, connectionSummary, {
@ -57,6 +59,7 @@ export function createTokenChannelApi(channel, connectionSummary, {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),

View file

@ -4,6 +4,7 @@ import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-b
import { h } from '../../i18n.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -73,7 +74,7 @@ export function createTokenChannelSettings(definition) {
accountSettingsEndpoint = null,
} = definition;
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAgentPresetSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAgentPresetSave, onContextEnhancementSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@ -105,6 +106,11 @@ export function createTokenChannelSettings(definition) {
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
AccountSettings ? h(AccountSettings, {
account,
busy: Boolean(busy),
@ -309,6 +315,12 @@ export function createTokenChannelSettings(definition) {
endpoints.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onContextEnhancementSave: (config) => botAction(
account,
'context-enhancement',
endpoints.setContextEnhancement,
{ botId: account.botId, config },
),
onAccountSettingsSave: AccountSettings && accountSettingsEndpoint
? (payload) => botAction(
account,

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WECOM_RPC_CHANNEL = '/wecom';
@ -13,6 +14,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -96,6 +98,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -4,6 +4,7 @@ import { WecomLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -165,6 +166,7 @@ export function AccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -197,6 +199,11 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' },
@ -499,6 +506,12 @@ export function WecomSettingsTab({ rpcCall }) {
WECOM_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onContextEnhancementSave: (config) => botAction(
account,
'context-enhancement',
WECOM_ENDPOINTS.setContextEnhancement,
{ botId: account.botId, config },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WEIXIN_RPC_CHANNEL = '/weixin';
export const WEIXIN_ENDPOINTS = Object.freeze({
@ -12,6 +13,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -121,6 +123,7 @@ function normalizeBot(value) {
configured: value.configured === true,
workspace: string(value.workspace).slice(0, 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),

View file

@ -15,6 +15,7 @@ import {
} from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -207,6 +208,7 @@ export function AccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -238,6 +240,12 @@ export function AccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
groupSupported: false,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'dxw-actions dim-cardActions' },
@ -280,6 +288,7 @@ function AccountList(props) {
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
onContextEnhancementSave: (config) => props.onContextEnhancementSave(account, config),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -594,13 +603,13 @@ export function WeixinSettingsTab({ rpcCall }) {
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'preset');
setBotBusy(account.botId, operation);
try {
const snapshot = normalizeSnapshot(await invoke(
WEIXIN_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
endpoint,
{ botId: account.botId, ...payload },
));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
@ -703,7 +712,12 @@ export function WeixinSettingsTab({ rpcCall }) {
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onAgentPresetSave: saveAgentPreset,
onAgentPresetSave: (account, agentPreset) => saveBotSetting(
account, 'preset', WEIXIN_ENDPOINTS.setAgentPreset, { agentPreset },
),
onContextEnhancementSave: (account, config) => saveBotSetting(
account, 'context-enhancement', WEIXIN_ENDPOINTS.setContextEnhancement, { config },
),
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -1,5 +1,6 @@
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
import { normalizeLastMessageError } from '../../last-message-error.js';
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setAccessPolicy: 'bot.access-policy.set',
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
@ -88,6 +90,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset),
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
accessPolicy: {
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
value.accessPolicy?.accessMode,

View file

@ -4,6 +4,7 @@ import { WhatsappLogoGlyph } from '../../channel-logos.js';
import { QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { ContextEnhancementEditor } from '../../context-enhancement.js';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
@ -297,6 +298,7 @@ export function WhatsappAccountCard({
onReconnect,
onWorkspaceSave,
onAgentPresetSave,
onContextEnhancementSave,
onAccessPolicySave,
onRequestRemove,
onConfirmRemove,
@ -334,6 +336,11 @@ export function WhatsappAccountCard({
disabled: Boolean(busy),
onSave: onAgentPresetSave,
}),
h(ContextEnhancementEditor, {
config: account.contextEnhancement,
disabled: Boolean(busy),
onSave: onContextEnhancementSave,
}),
h(WhatsappAccessSettings, {
account,
busy: Boolean(busy),
@ -596,6 +603,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset },
),
onContextEnhancementSave: (config) => botAction(
account,
'context-enhancement',
WHATSAPP_ENDPOINTS.setContextEnhancement,
{ botId: account.botId, config },
),
onAccessPolicySave: (accessPolicy) => botAction(
account,
'access',

View file

@ -0,0 +1,275 @@
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',
}, '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 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, '用于告诉模型如何使用 <dsh_im_source> 中已选择的来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。'),
h('strong', null, '生效规则'),
h('span', null, '只需填写正文,插件自动添加 <dsh_im_source_guidance> 成对标签。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。'),
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);
}

View file

@ -208,6 +208,44 @@ const EN = Object.freeze({
'/绝对路径/到/工作区': '/absolute/path/to/workspace',
'修改': 'Change',
'保存': 'Save',
'上下文增强': 'Context enhancement',
'未开启': 'Not enabled',
'仅群聊': 'Groups only',
'仅私聊': 'Direct only',
'群聊和私聊': 'Groups and direct',
'关闭弹窗': 'Close dialog',
'查看上下文增强说明': 'View context enhancement details',
'启用范围': 'Enable in',
'群聊中启用': 'Enable in group chats',
'私聊中启用': 'Enable in direct chats',
'(当前渠道不支持群聊)': '(This channel does not support group chats)',
'来源字段': 'Source fields',
'查看来源字段说明': 'View source field details',
'渠道': 'Channel',
'会话类型': 'Conversation type',
'发送者标识': 'Sender ID',
'发送者昵称': 'Sender name',
'查看发送者昵称字段说明': 'View sender name field details',
'该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。': 'This field is not available on every channel. If the current message has no sender name, <dsh_im_source> omits senderName even when the field is selected.',
'机器人标识': 'Bot ID',
'增强提示词': 'Guidance',
'查看增强提示词使用说明': 'View guidance instructions',
'使用说明': 'How to use',
'用于告诉模型如何使用 <dsh_im_source> 中已选择的来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。': 'Tell the model how to use the selected fields in <dsh_im_source>. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.',
'生效规则': 'Behavior',
'隐私提示': 'Privacy',
'使用示例': 'Example',
'仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。\nconversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑': 'Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nWhen conversationType is group, reply more seriously. When conversationType is direct, make the reply humorous and funny.',
'填入示例': 'Use example',
'清空': 'Clear',
'选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。': 'Choose which conversations to enhance, which source fields to include, and how to use them. Only existing message metadata is used; no platform APIs are queried.',
'增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。': 'Reference these values by field name (such as senderId or conversationType) in the guidance. Only selected fields available in the current message are sent; no extra lookups are made.',
'只需填写正文,插件自动添加 <dsh_im_source_guidance> 成对标签。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。': 'Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags. Clear and save to omit guidance. Selected source fields still follow the conversation switches.',
'发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。': 'Sender IDs may contain platform user IDs or phone-number-like identifiers. Turning this off does not remove information already saved in conversation history.',
'上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
'请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
'群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
'来源字段只能选择已定义的五个字段。': 'Source fields must be chosen from the five defined fields.',
'保存中…': 'Saving…',
'未设置': 'Not set',
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
@ -657,6 +695,8 @@ function channelName(value) {
}
function translateDynamic(text) {
const guidanceLimit = /^增强提示词不得超过 (\d+) 个字符。$/.exec(text);
if (guidanceLimit) return `Guidance must not exceed ${guidanceLimit[1]} characters.`;
let match = /^(\d+) \/ (\d+) 在线$/.exec(text);
if (match) return `${match[1]}/${match[2]} online`;
match = /^已接入 (\d+) 个机器人,其中 (\d+) 个在线$/.exec(text);

View file

@ -211,6 +211,73 @@ const CSS = String.raw`
.dim-panel .dim-presetSelect { min-width: 0; max-width: 100%; grid-column: 1 / -1; grid-row: 2; height: 30px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
.dim-panel .dim-presetSelect:disabled { cursor: not-allowed; opacity: .55; }
.dim-panel .dim-presetError { grid-column: 1 / -1; grid-row: 3; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.4; }
.dim-contextEntry { width: 100%; min-height: 40px; display: grid; grid-template-columns: 16px minmax(0, 1fr) max-content 16px; align-items: center; gap: 9px; margin: 10px 0; padding: 8px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 13px; line-height: 20px; text-align: left; cursor: pointer; }
.dim-contextEntry:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
.dim-contextEntry > svg { color: var(--dsw-alias-label-secondary, #646a73); }
.dim-contextLabel { min-width: 0; font-weight: 500; overflow-wrap: anywhere; }
.dim-contextStatus { padding: 2px 7px; border-radius: 5px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 11px; line-height: 16px; font-weight: 400; white-space: nowrap; }
.dim-contextStatus[data-active="true"] { color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 10%, var(--dsw-alias-bg-layer-1, #fff)); }
.dim-contextBackdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 12px; background: rgb(15 17 21 / 42%); }
.dim-contextBackdrop, .dim-contextBackdrop *, .dim-contextBackdrop *::before, .dim-contextBackdrop *::after { box-sizing: border-box; }
.dim-contextDialog { width: min(450px, 100%); min-width: 0; max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow-y: auto; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 20px 70px rgb(0 0 0 / 20%); font-size: 13px; line-height: 1.5; text-align: left; }
.dim-contextDialog:focus { outline: none; }
.dim-contextHeader, .dim-contextEditorHeader { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.dim-contextHeader { position: relative; }
.dim-contextHeaderTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
.dim-contextHeader h3 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 500; }
.dim-contextHeaderTooltip { width: min(340px, calc(100vw - 72px)); }
.dim-contextClose { width: 30px; height: 30px; flex: none; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; }
.dim-contextClose:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
.dim-contextSection { min-width: 0; margin: 14px 0; padding: 0; border: 0; }
.dim-contextSection legend { margin-bottom: 8px; padding: 0; font-weight: 500; }
.dim-contextLegend { position: relative; display: inline-flex; align-items: center; gap: 6px; }
.dim-contextSwitches { display: grid; grid-template-columns: 1fr; gap: 2px; }
.dim-contextSwitchRow { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 34px; cursor: pointer; }
.dim-contextSwitchLabel { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; flex-wrap: wrap; }
.dim-contextUnavailable { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 16px; font-weight: 400; }
.dim-contextSwitch { appearance: none; flex: none; width: 32px; height: 19px; margin: 0; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-interactive-bg-hover, #eef0f3); cursor: pointer; }
.dim-contextSwitch::before { content: ""; display: block; width: 13px; height: 13px; margin: 2px; border-radius: 50%; background: var(--dsw-alias-label-secondary, #646a73); }
.dim-contextSwitch:checked { border-color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-contextSwitch:checked::before { transform: translateX(13px); background: #fff; }
.dim-contextFields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; }
.dim-contextField { min-width: 0; min-height: 30px; display: flex; align-items: center; gap: 6px; }
.dim-contextField input { flex: none; width: 14px; height: 14px; margin: 0; accent-color: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-contextFieldText { min-width: 0; display: grid; grid-template-columns: max-content max-content; align-items: center; column-gap: 5px; overflow-wrap: anywhere; }
.dim-contextFieldName { min-width: 0; line-height: 17px; cursor: pointer; }
.dim-contextFieldKey { min-width: 0; grid-column: 1 / -1; color: var(--dsw-alias-label-tertiary, #8f959e); font: 10px/14px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; cursor: pointer; }
.dim-contextFieldHelp { position: relative; }
.dim-contextFieldHelpButton { width: 16px; height: 16px; font-size: 10px; }
.dim-contextTooltip.dim-contextFieldTooltip { top: calc(100% + 6px); right: 0; left: auto; width: min(280px, calc(100vw - 72px)); }
.dim-contextEditorHeader { position: relative; flex-wrap: wrap; }
.dim-contextEditorTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
.dim-contextEditorTitle > label { font-weight: 500; }
.dim-contextHelp { display: inline-flex; align-items: center; flex: none; }
.dim-contextHelpButton { width: 18px; height: 18px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 11px; line-height: 1; font-weight: 700; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; }
.dim-contextHelpButton:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
.dim-contextTooltip { position: absolute; top: calc(100% + 7px); left: 0; z-index: 30; width: min(330px, calc(100vw - 72px)); display: grid; gap: 5px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); font-size: 11px; line-height: 16px; font-weight: 400; overflow-wrap: anywhere; white-space: normal; opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dim-contextTooltip strong { font-weight: 600; }
.dim-contextTooltipExample { padding: 7px 8px; border-radius: 6px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
.dim-contextTooltip.dim-contextLegendTooltip { width: min(350px, calc(100vw - 72px)); }
.dim-contextTooltip.dim-contextGuidanceTooltip { top: auto; bottom: calc(100% + 7px); width: min(380px, calc(100vw - 72px)); max-height: calc(100dvh - 48px); overflow-y: auto; }
.dim-contextHelp:hover .dim-contextTooltip, .dim-contextHelp:focus-within .dim-contextTooltip { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; }
.dim-contextTextActions { display: flex; gap: 10px; margin-left: auto; }
.dim-contextTextActions button { min-height: 30px; padding: 4px 0; border: 0; border-radius: 4px; color: var(--dsw-alias-state-business-primary, #3370ff); background: transparent; font: inherit; font-size: 12px; cursor: pointer; }
.dim-contextTextActions button:hover:not(:disabled) { text-decoration: underline; }
.dim-contextGuidance textarea { display: block; width: 100%; min-height: 88px; margin-top: 7px; padding: 9px 10px; resize: vertical; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 12px; line-height: 1.6; }
.dim-contextGuidance textarea::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); opacity: 1; }
.dim-contextHint { margin: 5px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
.dim-contextError { margin: 12px 0 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.dim-contextFooter { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.dim-contextFooter button { min-height: 34px; padding: 6px 15px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; cursor: pointer; }
.dim-contextFooter button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
.dim-contextFooter .dim-contextSave, .dim-contextFooter .dim-contextSave:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
.dim-contextEntry:focus-visible, .dim-contextDialog button:focus-visible, .dim-contextDialog input:focus-visible, .dim-contextDialog textarea:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
.dim-contextEntry:disabled, .dim-contextDialog button:disabled, .dim-contextDialog input:disabled, .dim-contextDialog textarea:disabled { opacity: .55; cursor: not-allowed; }
@media (pointer: coarse) {
.dim-contextEntry, .dim-contextClose, .dim-contextFooter button, .dim-contextTextActions button, .dim-contextField, .dim-contextSwitchRow { min-height: 44px; }
.dim-contextClose, .dim-contextTextActions button { min-width: 44px; }
.dim-contextGuidance textarea { font-size: 16px; }
}
.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); }
.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; }
.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); }