feat: support runtime config (#12902)

* feat: support runtime config

* fix: cloud sandbox default tool ids
This commit is contained in:
Rdmclin2
2026-03-11 23:43:33 +08:00
committed by GitHub
parent 21d1f0e472
commit 14dd5d09dd
29 changed files with 589 additions and 193 deletions

View File

@@ -241,6 +241,14 @@
"rag.userQuery.actions.regenerate": "Regenerate Query",
"regenerate": "Regenerate",
"roleAndArchive": "Agent Profile & History",
"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": "None",
"runtimeEnv.mode.noneDesc": "No runtime environment",
"runtimeEnv.selectMode": "Select Runtime Environment",
"runtimeEnv.title": "Runtime Environment",
"search.grounding.imageSearchQueries": "Image Search Keywords",
"search.grounding.imageTitle": "Found {{count}} images",
"search.grounding.searchQueries": "Search Keywords",
@@ -391,6 +399,7 @@
"tokenTag.overload": "Exceeded Limit",
"tokenTag.remained": "Remaining",
"tokenTag.used": "Used",
"tool.intervention.approvalMode": "Approval Mode",
"tool.intervention.approve": "Approve",
"tool.intervention.approveAndRemember": "Approve and Remember",
"tool.intervention.approveOnce": "Approve This Time Only",

View File

@@ -241,6 +241,14 @@
"rag.userQuery.actions.regenerate": "重新生成 Query",
"regenerate": "重新生成",
"roleAndArchive": "助理档案与记录",
"runtimeEnv.mode.cloud": "云端沙箱",
"runtimeEnv.mode.cloudDesc": "在安全的云端沙箱中运行",
"runtimeEnv.mode.local": "本地",
"runtimeEnv.mode.localDesc": "访问本地文件和命令",
"runtimeEnv.mode.none": "不启用",
"runtimeEnv.mode.noneDesc": "不启用运行环境",
"runtimeEnv.selectMode": "选择运行环境",
"runtimeEnv.title": "运行环境",
"search.grounding.imageSearchQueries": "图片搜索关键词",
"search.grounding.imageTitle": "找到 {{count}} 张图片",
"search.grounding.searchQueries": "搜索关键词",
@@ -391,6 +399,7 @@
"tokenTag.overload": "超出限制",
"tokenTag.remained": "剩余",
"tokenTag.used": "已使用",
"tool.intervention.approvalMode": "审批模式",
"tool.intervention.approve": "批准",
"tool.intervention.approveAndRemember": "批准并记住",
"tool.intervention.approveOnce": "仅本次批准",

View File

@@ -29,6 +29,8 @@ export const defaultToolIds = [
WebBrowsingManifest.identifier,
KnowledgeBaseManifest.identifier,
MemoryManifest.identifier,
LocalSystemManifest.identifier,
CloudSandboxManifest.identifier,
];
export const builtinTools: LobeBuiltinTool[] = [
@@ -55,7 +57,7 @@ export const builtinTools: LobeBuiltinTool[] = [
},
{
discoverable: isDesktop,
hidden: !isDesktop,
hidden: true,
identifier: LocalSystemManifest.identifier,
manifest: LocalSystemManifest,
type: 'builtin',
@@ -73,6 +75,7 @@ export const builtinTools: LobeBuiltinTool[] = [
type: 'builtin',
},
{
hidden: true,
identifier: CloudSandboxManifest.identifier,
manifest: CloudSandboxManifest,
type: 'builtin',

View File

@@ -8,15 +8,25 @@
export type AgentMode = 'auto' | 'plan' | 'ask' | 'implement';
/**
* Local System configuration (desktop only)
* Runtime environment mode
* - local: Access local files and commands (desktop only)
* - cloud: Run in cloud sandbox
* - none: No runtime environment
*/
export interface LocalSystemConfig {
export type RuntimeEnvMode = 'cloud' | 'local' | 'none';
export type RuntimePlatform = 'desktop' | 'web';
/**
* Runtime environment configuration
*/
export interface RuntimeEnvConfig {
/**
* Local System working directory (desktop only)
* Runtime environment mode per platform
*/
runtimeMode?: Partial<Record<RuntimePlatform, RuntimeEnvMode>>;
/**
* Working directory (desktop only)
*/
workingDirectory?: string;
// Future extensions:
// allowedPaths?: string[];
// deniedCommands?: string[];
}

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
import { type SearchMode } from '../search';
import { type UserMemoryEffort } from '../user/settings/memory';
import { type LocalSystemConfig } from './agentConfig';
import { type RuntimeEnvConfig } from './agentConfig';
export interface WorkingModel {
model: string;
@@ -95,12 +95,12 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig {
*/
imageResolution2?: '512px' | '1K' | '2K' | '4K';
inputTemplate?: string;
/**
* Local System configuration (desktop only)
*/
localSystem?: LocalSystemConfig;
reasoningBudgetToken?: number;
reasoningEffort?: 'low' | 'medium' | 'high';
/**
* Runtime environment configuration (desktop only)
*/
runtimeEnv?: RuntimeEnvConfig;
searchFCModel?: WorkingModel;
searchMode?: SearchMode;
@@ -130,9 +130,12 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig {
}
/**
* Zod schema for LocalSystemConfig
* Zod schema for RuntimeEnvConfig
*/
export const LocalSystemConfigSchema = z.object({
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none']);
export const RuntimeEnvConfigSchema = z.object({
runtimeMode: z.record(z.string(), runtimeEnvModeEnum).optional(),
workingDirectory: z.string().optional(),
});
@@ -173,7 +176,7 @@ export const AgentChatConfigSchema = z
imageAspectRatio2: z.string().optional(),
imageResolution: z.enum(['1K', '2K', '4K']).optional(),
imageResolution2: z.enum(['512px', '1K', '2K', '4K']).optional(),
localSystem: LocalSystemConfigSchema.optional(),
runtimeEnv: RuntimeEnvConfigSchema.optional(),
reasoningBudgetToken: z.number().optional(),
reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
searchFCModel: z

View File

@@ -18,6 +18,7 @@ import { systemStatusSelectors } from '@/store/global/selectors';
import { type ActionToolbarProps } from '../ActionBar';
import ActionBar from '../ActionBar';
import InputEditor from '../InputEditor';
import RuntimeConfig from '../RuntimeConfig';
import SendArea from '../SendArea';
import TypoBar from '../TypoBar';
import ContextContainer from './ContextContainer';
@@ -59,11 +60,13 @@ interface DesktopChatInputProps extends ActionToolbarProps {
leftContent?: ReactNode;
sendAreaPrefix?: ReactNode;
showFootnote?: boolean;
showRuntimeConfig?: boolean;
}
const DesktopChatInput = memo<DesktopChatInputProps>(
({
showFootnote,
showRuntimeConfig = true,
inputContainerProps,
extentHeaderContent,
actionBarStyle,
@@ -151,6 +154,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
>
<InputEditor />
</ChatInput>
{showRuntimeConfig && <RuntimeConfig />}
{showFootnote && !expand && (
<Center style={{ pointerEvents: 'none', zIndex: 100 }}>
<Text className={styles.footnote} type={'secondary'}>

View File

@@ -1,14 +1,13 @@
import { type MenuProps } from '@lobehub/ui';
import { Button, Center, DropdownMenu, Icon } from '@lobehub/ui';
import { Button, Center, DropdownMenu, Icon, Tooltip } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Check, ChevronDown, Hand, ListChecks, Zap } from 'lucide-react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { toolInterventionSelectors } from '@/store/user/selectors';
import { type ApprovalMode } from './index';
import { type ApprovalMode } from '@/store/user/slices/settings/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
icon: css`
@@ -39,6 +38,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const ModeSelector = memo(() => {
const { t } = useTranslation('chat');
const [dropdownOpen, setDropdownOpen] = useState(false);
const approvalMode = useUserStore(toolInterventionSelectors.approvalMode);
const updateHumanIntervention = useUserStore((s) => s.updateHumanIntervention);
@@ -112,18 +112,33 @@ const ModeSelector = memo(() => {
[approvalMode, modeLabels, handleModeChange, styles, t],
);
const button = (
<Button
className={styles.modeButton}
color={'default'}
icon={ChevronDown}
iconPlacement="end"
size="small"
variant={'text'}
>
{modeLabels[approvalMode]}
</Button>
);
return (
<DropdownMenu items={menuItems} placement="bottomLeft">
<Button
className={styles.modeButton}
color={'default'}
icon={ChevronDown}
iconPlacement="end"
size="small"
variant={'text'}
>
{modeLabels[approvalMode]}
</Button>
<DropdownMenu
items={menuItems}
open={dropdownOpen}
placement="bottomLeft"
onOpenChange={setDropdownOpen}
>
<div>
{dropdownOpen ? (
button
) : (
<Tooltip title={t('tool.intervention.approvalMode')}>{button}</Tooltip>
)}
</div>
</DropdownMenu>
);
});

View File

@@ -27,7 +27,7 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
const activeTopicId = useChatStore((s) => s.activeTopicId);
// Actions
const updateAgentLocalSystemConfig = useAgentStore((s) => s.updateAgentLocalSystemConfigById);
const updateAgentRuntimeEnvConfig = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById);
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
// Local state for editing
@@ -58,7 +58,7 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
setLoading(true);
try {
// Save agent working directory
await updateAgentLocalSystemConfig(agentId, {
await updateAgentRuntimeEnvConfig(agentId, {
workingDirectory: agentDir || undefined,
});
@@ -85,7 +85,7 @@ const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, o
useTopicOverride,
topicDir,
topicWorkingDirectory,
updateAgentLocalSystemConfig,
updateAgentRuntimeEnvConfig,
updateTopicMetadata,
onClose,
]);

View File

@@ -0,0 +1,276 @@
import { isDesktop } from '@lobechat/const';
import { type RuntimeEnvMode } from '@lobechat/types';
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import {
ChevronDownIcon,
CloudIcon,
FolderIcon,
LaptopIcon,
MonitorOffIcon,
SquircleDashed,
} from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useAgentId } from '../hooks/useAgentId';
import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
import ApprovalMode from './ApprovalMode';
import WorkingDirectory from './WorkingDirectory';
const MODE_ICONS: Record<RuntimeEnvMode, typeof LaptopIcon> = {
cloud: CloudIcon,
local: LaptopIcon,
none: MonitorOffIcon,
};
const styles = createStaticStyles(({ css }) => ({
bar: css`
padding-block: 0;
padding-inline: 4px;
`,
button: css`
cursor: pointer;
display: flex;
gap: 6px;
align-items: center;
height: 28px;
padding-inline: 8px;
border-radius: 6px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
transition: all 0.2s;
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillSecondary};
}
`,
modeDesc: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
modeOption: css`
cursor: pointer;
width: 100%;
padding-block: 8px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
modeOptionActive: css`
background: ${cssVar.colorFillTertiary};
`,
modeOptionDesc: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
modeOptionIcon: css`
border: 1px solid ${cssVar.colorFillTertiary};
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgElevated};
`,
modeOptionTitle: css`
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
const RuntimeConfig = memo(() => {
const { t } = useTranslation('chat');
const { t: tPlugin } = useTranslation('plugin');
const agentId = useAgentId();
const { updateAgentChatConfig } = useUpdateAgentConfig();
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const [isLoading, runtimeMode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
]);
// Get working directory
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
);
const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory;
const switchMode = useCallback(
async (mode: RuntimeEnvMode) => {
if (mode === runtimeMode) return;
const platform = isDesktop ? 'desktop' : 'web';
await updateAgentChatConfig({
runtimeEnv: { runtimeMode: { [platform]: mode } },
});
},
[runtimeMode, updateAgentChatConfig],
);
// Skeleton placeholder to prevent layout jump during loading
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4}>
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 64, width: 64 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
</Flexbox>
);
}
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',
},
];
const modeContent = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
{modes.map(({ mode, icon, label, desc }) => (
<Flexbox
horizontal
align={'flex-start'}
className={cx(styles.modeOption, runtimeMode === mode && styles.modeOptionActive)}
gap={12}
key={mode}
onClick={() => switchMode(mode)}
>
<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>
</Flexbox>
))}
</Flexbox>
);
const modeButton = (
<div className={styles.button}>
<Icon icon={ModeIcon} size={14} />
<span>{modeLabel}</span>
<Icon icon={ChevronDownIcon} size={12} />
</div>
);
const dirButton = (
<div className={styles.button}>
<Icon icon={effectiveWorkingDirectory ? FolderIcon : SquircleDashed} size={14} />
<span>{displayName}</span>
<Icon icon={ChevronDownIcon} size={12} />
</div>
);
const rightContent = () => {
if (runtimeMode === 'local') {
return (
<Popover
content={<WorkingDirectory agentId={agentId} onClose={() => setDirPopoverOpen(false)} />}
open={dirPopoverOpen}
placement="bottomRight"
trigger="click"
onOpenChange={setDirPopoverOpen}
>
<div>
{dirPopoverOpen ? (
dirButton
) : (
<Tooltip
title={effectiveWorkingDirectory || tPlugin('localSystem.workingDirectory.notSet')}
>
{dirButton}
</Tooltip>
)}
</div>
</Popover>
);
}
return null;
};
return (
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
{/* Left: Runtime env + working directory */}
<Flexbox horizontal align={'center'} gap={4}>
<Popover
content={modeContent}
open={modePopoverOpen}
placement="top"
styles={{ content: { padding: 4 } }}
trigger="click"
onOpenChange={setModePopoverOpen}
>
<div>
{modePopoverOpen ? (
modeButton
) : (
<Tooltip title={t('runtimeEnv.selectMode')}>{modeButton}</Tooltip>
)}
</div>
</Popover>
{rightContent()}
</Flexbox>
{/* Right: Permission control */}
<ApprovalMode />
</Flexbox>
);
});
RuntimeConfig.displayName = 'RuntimeConfig';
export default RuntimeConfig;

View File

@@ -11,7 +11,6 @@ import { useConversationStore } from '../../../../../store';
import Arguments from '../Arguments';
import ApprovalActions from './ApprovalActions';
import KeyValueEditor from './KeyValueEditor';
import ModeSelector from './ModeSelector';
interface FallbackInterventionProps {
apiName: string;
@@ -77,8 +76,7 @@ const FallbackIntervention = memo<FallbackInterventionProps>(
}
/>
<Flexbox horizontal justify={'space-between'}>
<ModeSelector />
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}

View File

@@ -11,10 +11,9 @@ import Arguments from '../Arguments';
import ApprovalActions from './ApprovalActions';
import Fallback from './Fallback';
import KeyValueEditor from './KeyValueEditor';
import ModeSelector from './ModeSelector';
import SecurityBlacklistWarning from './SecurityBlacklistWarning';
export type ApprovalMode = 'auto-run' | 'allow-list' | 'manual';
export type { ApprovalMode } from '@/store/user/slices/settings/selectors';
interface InterventionProps {
apiName: string;
@@ -110,8 +109,7 @@ const Intervention = memo<InterventionProps>(
registerBeforeApprove={registerBeforeApprove}
onArgsChange={handleArgsChange}
/>
<Flexbox horizontal justify={'space-between'}>
<ModeSelector />
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}

View File

@@ -6,7 +6,6 @@ import { memo, Suspense } from 'react';
import AbortResponse from './AbortResponse';
import Intervention from './Intervention';
import ModeSelector from './Intervention/ModeSelector';
import LoadingPlaceholder from './LoadingPlaceholder';
import RejectedResponse from './RejectedResponse';
import ToolRender from './Render';
@@ -124,11 +123,6 @@ const Render = memo<RenderProps>(
type: type as any,
}}
/>
{!disableEditing && (
<div>
<ModeSelector />
</div>
)}
</Flexbox>
</Suspense>
);

View File

@@ -1,7 +1,9 @@
/**
* Tools Engineering - Unified tools processing using ToolsEngine
*/
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { defaultToolIds } from '@lobechat/builtin-tools';
@@ -102,7 +104,10 @@ export const createAgentToolsEngine = (workingModel: WorkingModel) => {
return undefined; // fall through to rules
},
rules: {
[CloudSandboxManifest.identifier]:
agentChatConfigSelectors.isCloudSandboxEnabled(agentState),
[KnowledgeBaseManifest.identifier]: agentSelectors.hasEnabledKnowledgeBases(agentState),
[LocalSystemManifest.identifier]: agentChatConfigSelectors.isLocalSystemEnabled(agentState),
[MemoryManifest.identifier]: agentChatConfigSelectors.isMemoryToolEnabled(agentState),
[WebBrowsingManifest.identifier]: searchConfig.useApplicationBuiltinSearchTool,
},

View File

@@ -273,6 +273,14 @@ export default {
'memory.title': 'Memory',
'search.grounding.imageSearchQueries': 'Image Search Keywords',
'search.grounding.imageTitle': 'Found {{count}} images',
'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.selectMode': 'Select Runtime Environment',
'runtimeEnv.title': 'Runtime Environment',
'search.grounding.searchQueries': 'Search Keywords',
'search.grounding.title': 'Found {{count}} results',
'search.mode.auto.desc': 'Search the web automatically when needed.',
@@ -425,6 +433,7 @@ export default {
'tokenTag.overload': 'Exceeded Limit',
'tokenTag.remained': 'Remaining',
'tokenTag.used': 'Used',
'tool.intervention.approvalMode': 'Approval Mode',
'tool.intervention.approve': 'Approve',
'tool.intervention.approveAndRemember': 'Approve and Remember',
'tool.intervention.approveOnce': 'Approve This Time Only',

View File

@@ -1,113 +0,0 @@
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { LaptopIcon, SquircleDashed } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import WorkingDirectoryContent from './WorkingDirectoryContent';
const styles = createStaticStyles(({ css, cssVar }) => {
return {
base: css`
border-radius: 6px;
color: ${cssVar.colorTextTertiary};
background-color: ${cssVar.colorFillTertiary};
:hover {
color: ${cssVar.colorTextSecondary};
background-color: ${cssVar.colorFillSecondary};
}
`,
filled: css`
font-family: ${cssVar.fontFamilyCode};
color: ${cssVar.colorText} !important;
`,
};
});
const WorkingDirectory = memo(() => {
const { t } = useTranslation('plugin');
const [open, setOpen] = useState(false);
const agentId = useAgentStore((s) => s.activeAgentId);
// Check if local-system plugin is enabled for current agent
const plugins = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentPluginsById(agentId)(s) : [],
);
const isLocalSystemEnabled = useMemo(
() => plugins.includes(LocalSystemManifest.identifier),
[plugins],
);
// Get working directory from Topic (higher priority) or Agent (fallback)
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
);
const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory;
// Only show when local-system is enabled and agent exists
if (!agentId || !isLocalSystemEnabled) return null;
// Get last folder name for display
const hasWorkingDirectory = !!effectiveWorkingDirectory;
const displayName = effectiveWorkingDirectory
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
: t('localSystem.workingDirectory.notSet');
const content = hasWorkingDirectory ? (
<Flexbox
horizontal
align="center"
className={cx(styles.base, styles.filled)}
gap={6}
style={{ cursor: 'pointer', height: 32, padding: '0 12px' }}
>
<Icon icon={LaptopIcon} size={18} />
<span>{displayName}</span>
</Flexbox>
) : (
<Flexbox
horizontal
align="center"
className={styles.base}
gap={6}
style={{ cursor: 'pointer', height: 32, padding: '0 12px' }}
>
<Icon icon={SquircleDashed} size={16} />
<span>{t('localSystem.workingDirectory.notSet')}</span>
</Flexbox>
);
return (
<Popover
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
open={open}
placement="bottomRight"
trigger="click"
onOpenChange={setOpen}
>
<div>
{open ? (
content
) : (
<Tooltip title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}>
{content}
</Tooltip>
)}
</div>
</Popover>
);
});
WorkingDirectory.displayName = 'WorkingDirectory';
export default WorkingDirectory;

View File

@@ -1,6 +1,5 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { memo } from 'react';
@@ -11,7 +10,6 @@ import HeaderActions from './HeaderActions';
import NotebookButton from './NotebookButton';
import ShareButton from './ShareButton';
import Tags from './Tags';
import WorkingDirectory from './WorkingDirectory';
const Header = memo(() => {
return (
@@ -23,7 +21,6 @@ const Header = memo(() => {
}
right={
<Flexbox horizontal align={'center'} style={{ backgroundColor: cssVar.colorBgContainer }}>
{isDesktop && <WorkingDirectory />}
<NotebookButton />
<ShareButton />
<HeaderActions />

View File

@@ -112,6 +112,7 @@ const InputArea = () => {
dropdownPlacement="bottomLeft"
extraActionItems={extraActionItems}
inputContainerProps={inputContainerProps}
showRuntimeConfig={false}
/>
</ChatInputProvider>
</DragUploadZone>

View File

@@ -16,7 +16,13 @@ const Home: FC = () => {
return (
<>
{isHomeRoute && <PageTitle title="" />}
<NavHeader right={<WideScreenButton />} />
<NavHeader
right={
<Flexbox horizontal align="center">
<WideScreenButton />
</Flexbox>
}
/>
<Flexbox height={'100%'} style={{ overflowY: 'auto', paddingBottom: '16vh' }} width={'100%'}>
<WideScreenContainer>
<HomeContent />

View File

@@ -9,6 +9,7 @@
* - Gets model capabilities from provided function
* - No dependency on frontend stores (useToolStore, useAgentStore, etc.)
*/
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
@@ -100,11 +101,19 @@ export const createServerAgentToolsEngine = (
const searchMode = agentConfig.chatConfig?.searchMode ?? 'auto';
const isSearchEnabled = searchMode !== 'off';
// Determine runtime mode based on platform
const isDesktopClient = !!deviceContext?.gatewayConfigured;
const platform = isDesktopClient ? 'desktop' : 'web';
const runtimeMode =
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
(isDesktopClient ? 'local' : 'none');
log(
'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, additionalManifests=%d, deviceGateway=%s',
'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, runtimeMode=%s, additionalManifests=%d, deviceGateway=%s',
model,
provider,
searchMode,
runtimeMode,
additionalManifests?.length ?? 0,
!!deviceContext?.gatewayConfigured,
);
@@ -116,9 +125,12 @@ export const createServerAgentToolsEngine = (
defaultToolIds,
enableChecker: createEnableChecker({
rules: {
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
[LocalSystemManifest.identifier]:
!!deviceContext?.gatewayConfigured && !!deviceContext?.deviceOnline,
runtimeMode === 'local' &&
!!deviceContext?.gatewayConfigured &&
!!deviceContext?.deviceOnline,
[MemoryManifest.identifier]: globalMemoryEnabled,
[RemoteDeviceManifest.identifier]: !!deviceContext?.gatewayConfigured,
[WebBrowsingManifest.identifier]: isSearchEnabled,

View File

@@ -1,5 +1,5 @@
import { type LobeToolManifest, type PluginEnableChecker } from '@lobechat/context-engine';
import { type LobeTool } from '@lobechat/types';
import { type LobeTool, type RuntimeEnvConfig } from '@lobechat/types';
/**
* Installed plugin with manifest
@@ -36,8 +36,9 @@ export interface ServerCreateAgentToolsEngineParams {
additionalManifests?: LobeToolManifest[];
/** Agent configuration containing plugins array */
agentConfig: {
/** Optional agent chat config with searchMode */
/** Optional agent chat config */
chatConfig?: {
runtimeEnv?: RuntimeEnvConfig;
searchMode?: 'off' | 'on' | 'auto';
};
/** Plugin IDs enabled for this agent */

View File

@@ -311,7 +311,7 @@ export class AgentRuntimeService {
userMemory,
userTimezone,
webhookDelivery,
workingDirectory: agentConfig?.chatConfig?.localSystem?.workingDirectory,
workingDirectory: agentConfig?.chatConfig?.runtimeEnv?.workingDirectory,
...appContext,
},
maxSteps,

View File

@@ -1,7 +1,7 @@
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG } from '@lobechat/const';
import { type AgentBuilderContext } from '@lobechat/context-engine';
import { type AgentMode, type LobeAgentTTSConfig, type LocalSystemConfig } from '@lobechat/types';
import { type AgentMode, type LobeAgentTTSConfig, type RuntimeEnvConfig } from '@lobechat/types';
import { type AgentStoreState } from '../initialState';
import { agentSelectors } from './selectors';
@@ -74,13 +74,13 @@ const getAgentEnableModeById =
};
/**
* Get local system config by agentId
* Now reads from chatConfig.localSystem
* Get runtime env config by agentId
* Now reads from chatConfig.runtimeEnv
*/
const getAgentLocalSystemConfigById =
const getAgentRuntimeEnvConfigById =
(agentId: string) =>
(s: AgentStoreState): LocalSystemConfig | undefined =>
agentSelectors.getAgentConfigById(agentId)(s)?.chatConfig?.localSystem;
(s: AgentStoreState): RuntimeEnvConfig | undefined =>
agentSelectors.getAgentConfigById(agentId)(s)?.chatConfig?.runtimeEnv;
/**
* Get working directory by agentId
@@ -88,7 +88,7 @@ const getAgentLocalSystemConfigById =
const getAgentWorkingDirectoryById =
(agentId: string) =>
(s: AgentStoreState): string | undefined =>
getAgentLocalSystemConfigById(agentId)(s)?.workingDirectory;
getAgentRuntimeEnvConfigById(agentId)(s)?.workingDirectory;
/**
* Get agent builder context by agentId
@@ -128,7 +128,7 @@ export const agentByIdSelectors = {
getAgentEnableModeById,
getAgentFilesById,
getAgentKnowledgeBasesById,
getAgentLocalSystemConfigById,
getAgentRuntimeEnvConfigById,
getAgentModeById,
getAgentModelById,
getAgentModelProviderById,

View File

@@ -13,6 +13,8 @@ vi.mock('@lobechat/model-runtime', () => ({
isThinkingWithToolClaudeModel: vi.fn((model) => model === 'claude-3-7-sonnet'),
}));
// isDesktop defaults to false in test environment (no __ELECTRON__)
const createState = (overrides: Partial<AgentStoreState> = {}): AgentStoreState => ({
...initialAgentSliceState,
...initialBuiltinAgentSliceState,
@@ -427,4 +429,127 @@ describe('chatConfigByIdSelectors', () => {
);
});
});
describe('getRuntimeEnvConfigById', () => {
it('should return runtimeEnv config for specified agent', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: {
runtimeEnv: { runtimeMode: { web: 'cloud' }, workingDirectory: '/home' },
},
},
},
});
expect(chatConfigByIdSelectors.getRuntimeEnvConfigById('agent-1')(state)).toEqual({
runtimeMode: { web: 'cloud' },
workingDirectory: '/home',
});
});
it('should return undefined when runtimeEnv is not set', () => {
const state = createState({
agentMap: { 'agent-1': {} },
});
expect(chatConfigByIdSelectors.getRuntimeEnvConfigById('agent-1')(state)).toBeUndefined();
});
});
// In test environment, isDesktop is false (no __ELECTRON__), so CURRENT_PLATFORM = 'web'
describe('getRuntimeModeById (web platform)', () => {
it('should return web runtime mode when set', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: { runtimeEnv: { runtimeMode: { web: 'cloud' } } },
},
},
});
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-1')(state)).toBe('cloud');
});
it('should default to "none" on web when not set', () => {
const state = createState({
agentMap: { 'agent-1': {} },
});
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-1')(state)).toBe('none');
});
it('should default to "none" for non-existent agent', () => {
const state = createState({ agentMap: {} });
expect(chatConfigByIdSelectors.getRuntimeModeById('non-existent')(state)).toBe('none');
});
it('should ignore desktop runtime mode on web', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: { runtimeEnv: { runtimeMode: { desktop: 'local' } } },
},
},
});
// desktop is set but web is not, should fall back to web default
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-1')(state)).toBe('none');
});
it('should read correct platform when both are set', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: {
runtimeEnv: { runtimeMode: { desktop: 'local', web: 'cloud' } },
},
},
},
});
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-1')(state)).toBe('cloud');
});
it('should work with different agents independently', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: { runtimeEnv: { runtimeMode: { web: 'cloud' } } },
},
'agent-2': {
chatConfig: { runtimeEnv: { runtimeMode: { web: 'none' } } },
},
'agent-3': { chatConfig: {} },
},
});
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-1')(state)).toBe('cloud');
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-2')(state)).toBe('none');
expect(chatConfigByIdSelectors.getRuntimeModeById('agent-3')(state)).toBe('none');
});
});
describe('isLocalSystemEnabledById', () => {
it('should return false on web even with desktop set to local', () => {
const state = createState({
agentMap: {
'agent-1': {
chatConfig: { runtimeEnv: { runtimeMode: { desktop: 'local' } } },
},
},
});
expect(chatConfigByIdSelectors.isLocalSystemEnabledById('agent-1')(state)).toBe(false);
});
it('should return false when not set (web defaults to none)', () => {
const state = createState({
agentMap: { 'agent-1': {} },
});
expect(chatConfigByIdSelectors.isLocalSystemEnabledById('agent-1')(state)).toBe(false);
});
});
});

View File

@@ -1,5 +1,9 @@
import { DEFAULT_AGENT_CHAT_CONFIG, DEFAULT_AGENT_SEARCH_FC_MODEL } from '@lobechat/const';
import { type LobeAgentChatConfig } from '@lobechat/types';
import {
DEFAULT_AGENT_CHAT_CONFIG,
DEFAULT_AGENT_SEARCH_FC_MODEL,
isDesktop,
} from '@lobechat/const';
import { type LobeAgentChatConfig, type RuntimeEnvMode } from '@lobechat/types';
import { type AgentStoreState } from '@/store/agent/initialState';
@@ -48,15 +52,37 @@ const isMemoryToolEnabledById = (agentId: string) => (s: AgentStoreState) =>
const getMemoryToolEffortById = (agentId: string) => (s: AgentStoreState) =>
getChatConfigById(agentId)(s).memory?.effort ?? 'medium';
const getRuntimeEnvConfigById = (agentId: string) => (s: AgentStoreState) =>
getChatConfigById(agentId)(s).runtimeEnv;
const isLocalSystemEnabledById = (agentId: string) => (s: AgentStoreState) =>
getRuntimeModeById(agentId)(s) === 'local';
/**
* Get runtime environment mode by agent ID.
* Reads from `runtimeMode[platform]`, defaults to 'local' on desktop, 'none' on web.
*/
const getRuntimeModeById =
(agentId: string) =>
(s: AgentStoreState): RuntimeEnvMode => {
const runtimeEnv = getChatConfigById(agentId)(s).runtimeEnv;
const platform = isDesktop ? 'desktop' : 'web';
return runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
};
export const chatConfigByIdSelectors = {
getChatConfigById,
getEnableHistoryCountById,
getHistoryCountById,
getRuntimeEnvConfigById,
getMemoryToolConfigById,
getMemoryToolEffortById,
getRuntimeModeById,
getSearchFCModelById,
getSearchModeById,
getUseModelBuiltinSearchById,
isEnableSearchById,
isLocalSystemEnabledById,
isMemoryToolEnabledById,
};

View File

@@ -31,6 +31,12 @@ const historyCount = (s: AgentStoreState): number =>
const isMemoryToolEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.isMemoryToolEnabledById(s.activeAgentId || '')(s);
const isLocalSystemEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.isLocalSystemEnabledById(s.activeAgentId || '')(s);
const isCloudSandboxEnabled = (s: AgentStoreState) =>
chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s) === 'cloud';
const enableHistoryDivider =
(historyLength: number, currentIndex: number) => (s: AgentStoreState) => {
const config = currentChatConfig(s);
@@ -49,6 +55,8 @@ export const agentChatConfigSelectors = {
enableHistoryDivider,
historyCount,
isAgentEnableSearch,
isCloudSandboxEnabled,
isLocalSystemEnabled,
isMemoryToolEnabled,
searchFCModel,
useModelBuiltinSearch,

View File

@@ -11,8 +11,8 @@ import {
type KnowledgeItem,
type LobeAgentConfig,
type LobeAgentTTSConfig,
type LocalSystemConfig,
type MetaData,
type RuntimeEnvConfig,
} from '@lobechat/types';
import { KnowledgeType } from '@lobechat/types';
import { VoiceList } from '@lobehub/tts';
@@ -248,17 +248,17 @@ const currentAgentMode = (s: AgentStoreState): AgentMode | undefined => {
const isAgentModeEnabled = (s: AgentStoreState): boolean => currentAgentMode(s) !== undefined;
/**
* Get current agent's local system config
* Now reads from chatConfig.localSystem
* Get current agent's runtime env config
* Now reads from chatConfig.runtimeEnv
*/
const currentAgentLocalSystemConfig = (s: AgentStoreState): LocalSystemConfig | undefined =>
currentAgentConfig(s)?.chatConfig?.localSystem;
const currentAgentRuntimeEnvConfig = (s: AgentStoreState): RuntimeEnvConfig | undefined =>
currentAgentConfig(s)?.chatConfig?.runtimeEnv;
/**
* Get current agent's working directory
*/
const currentAgentWorkingDirectory = (s: AgentStoreState): string | undefined =>
currentAgentLocalSystemConfig(s)?.workingDirectory;
currentAgentRuntimeEnvConfig(s)?.workingDirectory;
const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentData(s)?.virtual;
@@ -269,7 +269,7 @@ export const agentSelectors = {
currentAgentDescription,
currentAgentFiles,
currentAgentKnowledgeBases,
currentAgentLocalSystemConfig,
currentAgentRuntimeEnvConfig,
currentAgentMeta,
currentAgentMode,
currentAgentModel,

View File

@@ -15,7 +15,7 @@ import { userProfileSelectors } from '@/store/user/selectors';
import {
type LobeAgentChatConfig,
type LobeAgentConfig,
type LocalSystemConfig,
type RuntimeEnvConfig,
} from '@/types/agent';
import { type MetaData } from '@/types/meta';
import { merge } from '@/utils/merge';
@@ -191,13 +191,13 @@ export class AgentSliceActionImpl {
await this.#get().optimisticUpdateAgentConfig(agentId, config, controller.signal);
};
updateAgentLocalSystemConfigById = async (
updateAgentRuntimeEnvConfigById = async (
agentId: string,
config: Partial<LocalSystemConfig>,
config: Partial<RuntimeEnvConfig>,
): Promise<void> => {
if (!agentId) return;
await this.#get().updateAgentChatConfigById(agentId, { localSystem: config });
await this.#get().updateAgentChatConfigById(agentId, { runtimeEnv: config });
};
updateAgentMeta = async (meta: Partial<MetaData>): Promise<void> => {

View File

@@ -2,4 +2,4 @@ export { userGeneralSettingsSelectors } from './general';
export { keyVaultsConfigSelectors } from './keyVaults';
export { settingsSelectors } from './settings';
export { systemAgentSelectors } from './systemAgent';
export { toolInterventionSelectors } from './toolIntervention';
export { type ApprovalMode, toolInterventionSelectors } from './toolIntervention';

View File

@@ -5,11 +5,11 @@ import { currentSettings } from './settings';
/**
* User-selectable approval modes (excludes 'headless' which is for backend async tasks only)
*/
type UserApprovalMode = 'auto-run' | 'allow-list' | 'manual';
export type ApprovalMode = 'auto-run' | 'allow-list' | 'manual';
const humanInterventionConfig = (s: UserStore) => currentSettings(s).tool?.humanIntervention || {};
const interventionApprovalMode = (s: UserStore): UserApprovalMode => {
const interventionApprovalMode = (s: UserStore): ApprovalMode => {
const mode = currentSettings(s).tool?.humanIntervention?.approvalMode;
// Filter out 'headless' mode as it's not user-selectable (fallback to auto-run as similar behavior)
if (mode === 'headless') return 'auto-run';