Compare commits

...

1 Commits

Author SHA1 Message Date
Arvin Xu e56dcf9538 feat: restructure runtime mode selector with device binding support
- Replace flat runtime mode options with hierarchical structure:
  Device group (bound devices from device-gateway) / Sandbox / Disabled
- Add 'sandbox' to RuntimeEnvMode, keep 'cloud' for backward compat
- Add deviceId field to RuntimeEnvConfig for device-specific selection
- New DeviceSelector component with platform-specific icons
- New deviceService wrapping tRPC device.listDevices query
- Update i18n with device/sandbox labels in zh-CN and en-US
2026-05-24 02:56:47 +08:00
10 changed files with 314 additions and 73 deletions
+4
View File
@@ -411,12 +411,16 @@
"rag.userQuery.actions.regenerate": "Regenerate Query",
"regenerate": "Regenerate",
"roleAndArchive": "Agent Profile & History",
"runtimeEnv.device.empty": "No devices available. Connect to gateway from the desktop app first.",
"runtimeEnv.mode.cloud": "Cloud Sandbox",
"runtimeEnv.mode.cloudDesc": "Run in a secure cloud sandbox",
"runtimeEnv.mode.local": "Local",
"runtimeEnv.mode.localDesc": "Access local files and commands",
"runtimeEnv.mode.none": "Off",
"runtimeEnv.mode.noneDesc": "Disable runtime environment",
"runtimeEnv.mode.sandbox": "Sandbox",
"runtimeEnv.mode.sandboxDesc": "Run in an isolated cloud sandbox",
"runtimeEnv.section.device": "Device",
"runtimeEnv.selectMode": "Select Runtime Environment",
"runtimeEnv.title": "Runtime Environment",
"search.grounding.imageSearchQueries": "Image Search Keywords",
+4
View File
@@ -411,12 +411,16 @@
"rag.userQuery.actions.regenerate": "重新生成 Query",
"regenerate": "重新生成",
"roleAndArchive": "助理档案与记录",
"runtimeEnv.device.empty": "暂无可用设备,请先在桌面端连接到网关",
"runtimeEnv.mode.cloud": "云端沙箱",
"runtimeEnv.mode.cloudDesc": "在安全的云端沙箱中运行",
"runtimeEnv.mode.local": "本地",
"runtimeEnv.mode.localDesc": "访问本地文件和命令",
"runtimeEnv.mode.none": "关闭",
"runtimeEnv.mode.noneDesc": "禁用运行时环境",
"runtimeEnv.mode.sandbox": "沙箱",
"runtimeEnv.mode.sandboxDesc": "在隔离的云端沙箱中运行",
"runtimeEnv.section.device": "设备",
"runtimeEnv.selectMode": "选择运行环境",
"runtimeEnv.title": "运行环境",
"search.grounding.imageSearchQueries": "图片搜索关键词",
+9 -3
View File
@@ -9,11 +9,12 @@ export type AgentMode = 'auto' | 'plan' | 'ask' | 'implement';
/**
* Runtime environment mode
* - local: Access local files and commands (desktop only)
* - cloud: Run in cloud sandbox
* - local: Run on a specific device (desktop only, requires deviceId)
* - sandbox: Run in isolated cloud sandbox
* - cloud: @deprecated Use 'sandbox' instead, kept for backward compatibility
* - none: No runtime environment
*/
export type RuntimeEnvMode = 'cloud' | 'local' | 'none';
export type RuntimeEnvMode = 'cloud' | 'local' | 'none' | 'sandbox';
export type RuntimePlatform = 'desktop' | 'web';
@@ -21,6 +22,11 @@ export type RuntimePlatform = 'desktop' | 'web';
* Runtime environment configuration
*/
export interface RuntimeEnvConfig {
/**
* Device ID when runtimeMode is 'local' (desktop only).
* Identifies which bound device to run on.
*/
deviceId?: string;
/**
* Runtime environment mode per platform
*/
+2 -1
View File
@@ -170,9 +170,10 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig, AgentSelfIte
/**
* Zod schema for RuntimeEnvConfig
*/
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none']);
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none', 'sandbox']);
export const RuntimeEnvConfigSchema = z.object({
deviceId: z.string().optional(),
runtimeMode: z.record(z.string(), runtimeEnvModeEnum).optional(),
workingDirectory: z.string().optional(),
});
@@ -0,0 +1,109 @@
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { LaptopIcon, MonitorIcon, ServerIcon } from 'lucide-react';
import { memo } from 'react';
const styles = createStaticStyles(({ css }) => ({
deviceName: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
deviceOption: css`
cursor: pointer;
width: 100%;
padding-block: 8px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
deviceOptionActive: css`
background: ${cssVar.colorFillTertiary};
`,
deviceOptionDesc: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
deviceOptionIcon: css`
flex-shrink: 0;
border: 1px solid ${cssVar.colorFillTertiary};
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgElevated};
`,
sectionTitle: css`
padding-block: 6px 2px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextQuaternary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
const PLATFORM_ICONS: Record<string, typeof LaptopIcon> = {
darwin: LaptopIcon,
linux: MonitorIcon,
win32: MonitorIcon,
};
interface DeviceSelectorProps {
activeDeviceId?: string;
devices: DeviceAttachment[];
onSelect: (deviceId: string) => void;
}
export const DeviceSelector = memo<DeviceSelectorProps>(
({ activeDeviceId, devices, onSelect }) => {
return (
<>
{devices.map((device) => {
const IconComp = PLATFORM_ICONS[device.platform] || ServerIcon;
const isActive = activeDeviceId === device.deviceId;
return (
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.deviceOption, isActive && styles.deviceOptionActive)}
gap={12}
key={device.deviceId}
onClick={() => onSelect(device.deviceId)}
>
<Flexbox
align={'center'}
className={styles.deviceOptionIcon}
height={32}
justify={'center'}
width={32}
>
<Icon icon={IconComp} size={16} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.deviceName}>{device.hostname}</div>
<div className={styles.deviceOptionDesc}>{device.platform}</div>
</Flexbox>
</Flexbox>
);
})}
</>
);
},
);
DeviceSelector.displayName = 'DeviceSelector';
/** Section header for device/sandbox/none groups */
export const SectionHeader = memo<{ label: string }>(({ label }) => (
<div className={styles.sectionTitle}>{label}</div>
));
SectionHeader.displayName = 'SectionHeader';
+142 -65
View File
@@ -4,6 +4,7 @@ import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import {
BoxIcon,
ChevronDownIcon,
CloudIcon,
FolderIcon,
@@ -12,9 +13,10 @@ import {
MonitorOffIcon,
SquircleDashed,
} from 'lucide-react';
import { memo, type ReactNode, useCallback, useMemo, useState } from 'react';
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { deviceService } from '@/services/device';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
@@ -26,6 +28,7 @@ import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
import { useChatInputStore } from '../store';
import ApprovalMode from './ApprovalMode';
import CloudRepoSwitcher from './CloudRepoSwitcher';
import { DeviceSelector, SectionHeader } from './DeviceSelector';
import GitStatus from './GitStatus';
import ModeSelector from './ModeSelector';
import { useRepoType } from './useRepoType';
@@ -35,6 +38,7 @@ const MODE_ICONS: Record<RuntimeEnvMode, typeof LaptopIcon> = {
cloud: CloudIcon,
local: LaptopIcon,
none: MonitorOffIcon,
sandbox: BoxIcon,
};
const styles = createStaticStyles(({ css }) => ({
@@ -63,6 +67,11 @@ const styles = createStaticStyles(({ css }) => ({
background: ${cssVar.colorFillSecondary};
}
`,
divider: css`
height: 1px;
margin-block: 4px;
background: ${cssVar.colorBorderSecondary};
`,
modeDesc: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
@@ -107,16 +116,21 @@ const RuntimeConfig = memo(() => {
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const [devices, setDevices] = useState<Awaited<ReturnType<typeof deviceService.listDevices>>>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const showContextWindow = useChatInputStore((s) =>
s.rightActions.flat().includes('contextWindow'),
);
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
]);
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode, deviceId] = useAgentStore(
(s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
chatConfigByIdSelectors.getDeviceIdById(agentId)(s),
],
);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const agentWorkingDirectory = useAgentStore((s) =>
@@ -126,6 +140,17 @@ const RuntimeConfig = memo(() => {
const repoType = useRepoType(effectiveWorkingDirectory);
// Fetch device list when popover opens (desktop only)
useEffect(() => {
if (modePopoverOpen && isDesktop) {
setDevicesLoading(true);
deviceService.listDevices().then((list) => {
setDevices(list);
setDevicesLoading(false);
});
}
}, [modePopoverOpen]);
const dirIconNode = useMemo((): ReactNode => {
if (!effectiveWorkingDirectory) return <Icon icon={SquircleDashed} size={14} />;
if (repoType === 'github') return <Github size={14} />;
@@ -134,18 +159,43 @@ const RuntimeConfig = memo(() => {
}, [effectiveWorkingDirectory, repoType]);
const switchMode = useCallback(
async (mode: RuntimeEnvMode) => {
if (mode === runtimeMode) return;
async (mode: RuntimeEnvMode, opts?: { deviceId?: string }) => {
if (mode === runtimeMode && opts?.deviceId === deviceId) return;
const platform = isDesktop ? 'desktop' : 'web';
await updateAgentChatConfig({
runtimeEnv: { runtimeMode: { [platform]: mode } },
runtimeEnv: {
deviceId: opts?.deviceId,
runtimeMode: { [platform]: mode },
},
});
},
[runtimeMode, updateAgentChatConfig],
[runtimeMode, deviceId, updateAgentChatConfig],
);
// Compute the display label for the mode button
const activeDevice = useMemo(
() => (deviceId ? devices.find((d) => d.deviceId === deviceId) : undefined),
[deviceId, devices],
);
const ModeIcon = MODE_ICONS[runtimeMode] || LaptopIcon;
const modeLabel = useMemo(() => {
// When running on a specific device, show device hostname
if (runtimeMode === 'local' && activeDevice) {
return activeDevice.hostname;
}
return t(`runtimeEnv.mode.${runtimeMode}`);
}, [runtimeMode, activeDevice, t]);
const displayName = effectiveWorkingDirectory
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
: tPlugin('localSystem.workingDirectory.notSet');
const hasDevices = devices.length > 0;
// Skeleton placeholder to prevent layout jump during loading
if (!agentId || isLoading) {
return (
@@ -156,66 +206,93 @@ const RuntimeConfig = memo(() => {
);
}
const ModeIcon = MODE_ICONS[runtimeMode];
const modeLabel = t(`runtimeEnv.mode.${runtimeMode}`);
const displayName = effectiveWorkingDirectory
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
: tPlugin('localSystem.workingDirectory.notSet');
const modes: { desc: string; icon: typeof LaptopIcon; label: string; mode: RuntimeEnvMode }[] = [
// Local mode is desktop-only
...(isDesktop
? [
{
desc: t('runtimeEnv.mode.localDesc'),
icon: LaptopIcon,
label: t('runtimeEnv.mode.local'),
mode: 'local' as RuntimeEnvMode,
},
]
: []),
{
desc: t('runtimeEnv.mode.cloudDesc'),
icon: CloudIcon,
label: t('runtimeEnv.mode.cloud'),
mode: 'cloud',
},
{
desc: t('runtimeEnv.mode.noneDesc'),
icon: MonitorOffIcon,
label: t('runtimeEnv.mode.none'),
mode: 'none',
},
];
// ─── Popover Content ───
const modeContent = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
{modes.map(({ mode, icon, label, desc }) => (
{/* ── Device section (desktop only) ── */}
{isDesktop && (
<>
<SectionHeader label={t('runtimeEnv.section.device')} />
{devicesLoading ? (
<Flexbox paddingBlock={12} paddingInline={8}>
<Skeleton.Button
active
size="small"
style={{ height: 16, marginBottom: 4, width: '60%' }}
/>
<Skeleton.Button active size="small" style={{ height: 12, width: '40%' }} />
</Flexbox>
) : hasDevices ? (
<DeviceSelector
activeDeviceId={deviceId}
devices={devices}
onSelect={(id) => switchMode('local', { deviceId: id })}
/>
) : (
<Flexbox
className={styles.modeOptionDesc}
paddingBlock={8}
paddingInline={8}
>
{t('runtimeEnv.device.empty')}
</Flexbox>
)}
<div className={styles.divider} />
</>
)}
{/* ── Sandbox ── */}
<Flexbox
horizontal
align={'flex-start'}
gap={12}
className={cx(
styles.modeOption,
(runtimeMode === 'sandbox' || runtimeMode === 'cloud') && styles.modeOptionActive,
)}
onClick={() => switchMode('sandbox')}
>
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.modeOption, runtimeMode === mode && styles.modeOptionActive)}
gap={12}
key={mode}
onClick={() => switchMode(mode)}
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
>
<Flexbox
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
>
<Icon icon={icon} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{label}</div>
<div className={styles.modeOptionDesc}>{desc}</div>
</Flexbox>
<Icon icon={BoxIcon} />
</Flexbox>
))}
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.sandbox')}</div>
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.sandboxDesc')}</div>
</Flexbox>
</Flexbox>
{/* ── Disabled ── */}
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.modeOption, runtimeMode === 'none' && styles.modeOptionActive)}
gap={12}
onClick={() => switchMode('none')}
>
<Flexbox
align={'center'}
className={styles.modeOptionIcon}
flex={'none'}
height={32}
justify={'center'}
width={32}
>
<Icon icon={MonitorOffIcon} />
</Flexbox>
<Flexbox flex={1}>
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.none')}</div>
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.noneDesc')}</div>
</Flexbox>
</Flexbox>
</Flexbox>
);
@@ -201,7 +201,7 @@ export const createServerAgentToolsEngine = (
// Always-on builtin tools
...Object.fromEntries(alwaysOnToolIds.map((id) => [id, true])),
// System-level rules (may override user selection for specific tools)
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud' || runtimeMode === 'sandbox',
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
// Local-system: gated by `canUseDevice` (resolveDeviceAccessPolicy)
// first — keeps external bot senders out before runtime checks even
+28
View File
@@ -0,0 +1,28 @@
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import { lambdaClient } from '@/libs/trpc/client';
export const deviceService = {
/**
* List all online devices bound to the current user.
* Returns devices from the device-gateway via tRPC.
*/
listDevices: async (): Promise<DeviceAttachment[]> => {
try {
return await lambdaClient.device.listDevices.query();
} catch {
return [];
}
},
/**
* Check if the user has any online devices.
*/
getStatus: async (): Promise<{ deviceCount: number; online: boolean }> => {
try {
return await lambdaClient.device.status.query();
} catch {
return { deviceCount: 0, online: false };
}
},
};
@@ -58,17 +58,26 @@ const getRuntimeEnvConfigById = (agentId: string) => (s: AgentStoreState) =>
const isLocalSystemEnabledById = (agentId: string) => (s: AgentStoreState) =>
getRuntimeModeById(agentId)(s) === 'local';
/** Get the selected device ID for the agent (desktop only) */
const getDeviceIdById =
(agentId: string) =>
(s: AgentStoreState): string | undefined =>
getChatConfigById(agentId)(s).runtimeEnv?.deviceId;
/**
* Get runtime environment mode by agent ID.
* Reads from `runtimeMode[platform]`, defaults to 'local' on desktop, 'none' on web.
* Legacy 'cloud' values are normalized to 'sandbox' for backward compatibility.
*/
const getRuntimeModeById =
(agentId: string) =>
(s: AgentStoreState): RuntimeEnvMode => {
const runtimeEnv = getChatConfigById(agentId)(s).runtimeEnv;
const platform = isDesktop ? 'desktop' : 'web';
const mode = runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
return runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
// Legacy backward compatibility: map 'cloud' to 'sandbox'
return mode === 'cloud' ? 'sandbox' : mode;
};
const getSkillActivateModeById =
@@ -78,6 +87,7 @@ const getSkillActivateModeById =
export const chatConfigByIdSelectors = {
getChatConfigById,
getDeviceIdById,
getEnableHistoryCountById,
getHistoryCountById,
getRuntimeEnvConfigById,
@@ -34,8 +34,10 @@ const isMemoryToolEnabled = (s: AgentStoreState) =>
const isLocalSystemEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.isLocalSystemEnabledById(s.activeAgentId || '')(s);
const isCloudSandboxEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s) === 'cloud';
const isCloudSandboxEnabled = (s: AgentStoreState) => {
const mode = chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s);
return mode === 'cloud' || mode === 'sandbox';
};
const skillActivateMode = (s: AgentStoreState) =>
chatConfigByIdSelectors.getSkillActivateModeById(s.activeAgentId || '')(s);