diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 24a719d863..779ac2856c 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -18,7 +18,10 @@ import { DevicesSection, ExecSection, LauncherSection, + RoutingSection, RuntimeSection, + SubTurnSection, + ToolSecuritySection, } from "@/components/config/config-sections" import { type CoreConfigForm, @@ -27,6 +30,7 @@ import { type LauncherForm, buildFormFromConfig, parseCIDRText, + parseFloatField, parseIntField, parseMultilineList, } from "@/components/config/form-model" @@ -180,6 +184,62 @@ export function ConfigPage() { "Cron exec timeout", { min: 0 }, ) + + // SubTurn fields + const subturnMaxDepth = parseIntField( + form.subturnMaxDepth, + "SubTurn max depth", + { min: 0 }, + ) + const subturnMaxConcurrent = parseIntField( + form.subturnMaxConcurrent, + "SubTurn max concurrent", + { min: 0 }, + ) + const subturnDefaultTimeoutMinutes = parseIntField( + form.subturnDefaultTimeoutMinutes, + "SubTurn default timeout", + { min: 0 }, + ) + const subturnDefaultTokenBudget = parseIntField( + form.subturnDefaultTokenBudget, + "SubTurn token budget", + { min: 0 }, + ) + const subturnConcurrencyTimeoutSec = parseIntField( + form.subturnConcurrencyTimeoutSec, + "SubTurn concurrency timeout", + { min: 0 }, + ) + + // Routing fields + if (form.routingEnabled && !form.routingLightModel.trim()) { + throw new Error("Light Model is required when Smart Routing is enabled.") + } + const routingThreshold = form.routingEnabled + ? parseFloatField(form.routingThreshold, "Routing threshold", { + min: 0, + max: 1, + }) + : undefined + + // Optional numeric fields + const temperature = form.temperature.trim() + ? parseFloatField(form.temperature, "Temperature", { + min: 0, + max: 2, + }) + : undefined + const maxMediaSize = form.maxMediaSize.trim() + ? parseIntField(form.maxMediaSize, "Max media size", { min: 0 }) + : undefined + const filterMinLength = + form.filterSensitiveData && form.filterMinLength.trim() + ? parseIntField(form.filterMinLength, "Filter min length", { + min: 0, + }) + : undefined + const execConfigPatch: Record = { enabled: form.execEnabled, } @@ -208,6 +268,10 @@ export function ConfigPage() { defaults: { workspace, restrict_to_workspace: form.restrictToWorkspace, + allow_read_outside_workspace: form.allowReadOutsideWorkspace, + steering_mode: form.steeringMode, + temperature, + max_media_size: maxMediaSize, tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, @@ -217,12 +281,28 @@ export function ConfigPage() { max_tool_iterations: maxToolIterations, summarize_message_threshold: summarizeMessageThreshold, summarize_token_percent: summarizeTokenPercent, + subturn: { + max_depth: subturnMaxDepth, + max_concurrent: subturnMaxConcurrent, + default_timeout_minutes: subturnDefaultTimeoutMinutes, + default_token_budget: subturnDefaultTokenBudget, + concurrency_timeout_sec: subturnConcurrencyTimeoutSec, + }, + routing: form.routingEnabled + ? { + enabled: true, + light_model: form.routingLightModel.trim() || undefined, + threshold: routingThreshold, + } + : { enabled: false }, }, }, session: { dm_scope: dmScope, }, tools: { + filter_sensitive_data: form.filterSensitiveData, + filter_min_length: filterMinLength, cron: { allow_command: form.allowCommand, exec_timeout_minutes: cronExecTimeoutMinutes, @@ -237,6 +317,12 @@ export function ConfigPage() { enabled: form.devicesEnabled, monitor_usb: form.monitorUSB, }, + voice: { + echo_transcription: form.voiceEchoTranscription, + }, + gateway: { + log_level: form.gatewayLogLevel, + }, }) setBaseline(form) @@ -322,10 +408,16 @@ export function ConfigPage() { + + + + + + o.value === form.steeringMode, + ) return ( @@ -192,6 +197,225 @@ export function AgentDefaultsSection({ } /> + + + onFieldChange("allowReadOutsideWorkspace", checked) + } + /> + + + + + + + onFieldChange("temperature", e.target.value)} + placeholder="0.7" + /> + + + + onFieldChange("maxMediaSize", e.target.value)} + placeholder="20971520" + /> + + + ) +} + +interface SubTurnSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField +} + +export function SubTurnSection({ form, onFieldChange }: SubTurnSectionProps) { + const { t } = useTranslation() + + return ( + + + onFieldChange("subturnMaxDepth", e.target.value)} + /> + + + + + onFieldChange("subturnMaxConcurrent", e.target.value) + } + /> + + + + + onFieldChange("subturnDefaultTimeoutMinutes", e.target.value) + } + /> + + + + + onFieldChange("subturnDefaultTokenBudget", e.target.value) + } + /> + + + + + onFieldChange("subturnConcurrencyTimeoutSec", e.target.value) + } + /> + + + ) +} + +interface RoutingSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField +} + +export function RoutingSection({ form, onFieldChange }: RoutingSectionProps) { + const { t } = useTranslation() + + return ( + + + onFieldChange("routingEnabled", checked) + } + /> + + {form.routingEnabled && ( + <> + + + onFieldChange("routingLightModel", e.target.value) + } + placeholder="gpt-4o-mini" + /> + + + + + onFieldChange("routingThreshold", e.target.value) + } + /> + + + )} ) } @@ -389,6 +613,9 @@ interface RuntimeSectionProps { export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) { const { t } = useTranslation() + const selectedLogLevelOption = LOG_LEVEL_OPTIONS.find( + (o) => o.value === form.gatewayLogLevel, + ) const selectedDmScopeOption = DM_SCOPE_OPTIONS.find( (scope) => scope.value === form.dmScope, ) @@ -453,6 +680,86 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) { /> )} + + + onFieldChange("voiceEchoTranscription", checked) + } + /> + + + + + + ) +} + +interface ToolSecuritySectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField +} + +export function ToolSecuritySection({ + form, + onFieldChange, +}: ToolSecuritySectionProps) { + const { t } = useTranslation() + + return ( + + + onFieldChange("filterSensitiveData", checked) + } + /> + + {form.filterSensitiveData && ( + + onFieldChange("filterMinLength", e.target.value)} + /> + + )} ) } diff --git a/web/frontend/src/components/config/form-model.ts b/web/frontend/src/components/config/form-model.ts index 10c5c71bb9..b06fc89bca 100644 --- a/web/frontend/src/components/config/form-model.ts +++ b/web/frontend/src/components/config/form-model.ts @@ -23,6 +23,28 @@ export interface CoreConfigForm { heartbeatInterval: string devicesEnabled: boolean monitorUSB: boolean + // Agent advanced + allowReadOutsideWorkspace: boolean + steeringMode: string + temperature: string + maxMediaSize: string + // SubTurn + subturnMaxDepth: string + subturnMaxConcurrent: string + subturnDefaultTimeoutMinutes: string + subturnDefaultTokenBudget: string + subturnConcurrencyTimeoutSec: string + // Routing + routingEnabled: boolean + routingLightModel: string + routingThreshold: string + // Tool security + filterSensitiveData: boolean + filterMinLength: string + // Voice + voiceEchoTranscription: boolean + // Gateway + gatewayLogLevel: string } export interface LauncherForm { @@ -62,6 +84,27 @@ export const DM_SCOPE_OPTIONS = [ }, ] as const +export const STEERING_MODE_OPTIONS = [ + { + value: "one-at-a-time", + labelKey: "pages.config.steering_mode_one_at_a_time", + labelDefault: "One at a Time", + }, + { + value: "all", + labelKey: "pages.config.steering_mode_all", + labelDefault: "All", + }, +] as const + +export const LOG_LEVEL_OPTIONS = [ + { value: "debug", labelKey: "pages.config.log_level_debug", labelDefault: "Debug" }, + { value: "info", labelKey: "pages.config.log_level_info", labelDefault: "Info" }, + { value: "warn", labelKey: "pages.config.log_level_warn", labelDefault: "Warn" }, + { value: "error", labelKey: "pages.config.log_level_error", labelDefault: "Error" }, + { value: "fatal", labelKey: "pages.config.log_level_fatal", labelDefault: "Fatal" }, +] as const + export const EMPTY_FORM: CoreConfigForm = { workspace: "", restrictToWorkspace: true, @@ -85,6 +128,28 @@ export const EMPTY_FORM: CoreConfigForm = { heartbeatInterval: "30", devicesEnabled: false, monitorUSB: true, + // Agent advanced + allowReadOutsideWorkspace: false, + steeringMode: "one-at-a-time", + temperature: "", + maxMediaSize: "", + // SubTurn + subturnMaxDepth: "0", + subturnMaxConcurrent: "0", + subturnDefaultTimeoutMinutes: "0", + subturnDefaultTokenBudget: "0", + subturnConcurrencyTimeoutSec: "0", + // Routing + routingEnabled: false, + routingLightModel: "", + routingThreshold: "0.5", + // Tool security + filterSensitiveData: true, + filterMinLength: "8", + // Voice + voiceEchoTranscription: false, + // Gateway + gatewayLogLevel: "info", } export const EMPTY_LAUNCHER_FORM: LauncherForm = { @@ -129,6 +194,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { const cron = asRecord(tools.cron) const exec = asRecord(tools.exec) const toolFeedback = asRecord(defaults.tool_feedback) + const subturn = asRecord(defaults.subturn) + const routing = asRecord(defaults.routing) + const voice = asRecord(root.voice) + const gateway = asRecord(root.gateway) return { workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, @@ -212,6 +281,64 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { devices.monitor_usb === undefined ? EMPTY_FORM.monitorUSB : asBool(devices.monitor_usb), + // Agent advanced + allowReadOutsideWorkspace: + defaults.allow_read_outside_workspace === undefined + ? EMPTY_FORM.allowReadOutsideWorkspace + : asBool(defaults.allow_read_outside_workspace), + steeringMode: asString(defaults.steering_mode) || EMPTY_FORM.steeringMode, + temperature: asNumberString(defaults.temperature, EMPTY_FORM.temperature), + maxMediaSize: asNumberString( + defaults.max_media_size, + EMPTY_FORM.maxMediaSize, + ), + // SubTurn + subturnMaxDepth: asNumberString( + subturn.max_depth, + EMPTY_FORM.subturnMaxDepth, + ), + subturnMaxConcurrent: asNumberString( + subturn.max_concurrent, + EMPTY_FORM.subturnMaxConcurrent, + ), + subturnDefaultTimeoutMinutes: asNumberString( + subturn.default_timeout_minutes, + EMPTY_FORM.subturnDefaultTimeoutMinutes, + ), + subturnDefaultTokenBudget: asNumberString( + subturn.default_token_budget, + EMPTY_FORM.subturnDefaultTokenBudget, + ), + subturnConcurrencyTimeoutSec: asNumberString( + subturn.concurrency_timeout_sec, + EMPTY_FORM.subturnConcurrencyTimeoutSec, + ), + // Routing + routingEnabled: + routing.enabled === undefined + ? EMPTY_FORM.routingEnabled + : asBool(routing.enabled), + routingLightModel: asString(routing.light_model) || EMPTY_FORM.routingLightModel, + routingThreshold: asNumberString( + routing.threshold, + EMPTY_FORM.routingThreshold, + ), + // Tool security + filterSensitiveData: + tools.filter_sensitive_data === undefined + ? EMPTY_FORM.filterSensitiveData + : asBool(tools.filter_sensitive_data), + filterMinLength: asNumberString( + tools.filter_min_length, + EMPTY_FORM.filterMinLength, + ), + // Voice + voiceEchoTranscription: + voice.echo_transcription === undefined + ? EMPTY_FORM.voiceEchoTranscription + : asBool(voice.echo_transcription), + // Gateway + gatewayLogLevel: asString(gateway.log_level) || EMPTY_FORM.gatewayLogLevel, } } @@ -233,6 +360,24 @@ export function parseIntField( return value } +export function parseFloatField( + rawValue: string, + label: string, + options: { min?: number; max?: number } = {}, +): number { + const value = Number(rawValue) + if (!Number.isFinite(value)) { + throw new Error(`${label} must be a number.`) + } + if (options.min !== undefined && value < options.min) { + throw new Error(`${label} must be >= ${options.min}.`) + } + if (options.max !== undefined && value > options.max) { + throw new Error(`${label} must be <= ${options.max}.`) + } + return value +} + export function parseCIDRText(raw: string): string[] { if (!raw.trim()) { return [] diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 8e051f42eb..422f521931 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -501,13 +501,57 @@ "allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "allow_read_outside_workspace": "Allow Read Outside Workspace", + "allow_read_outside_workspace_hint": "Allow the agent to read files outside the workspace directory.", + "steering_mode": "Steering Mode", + "steering_mode_hint": "How steering messages are injected into the agent loop. 'One at a Time' queues messages; 'All' delivers all pending messages at once.", + "steering_mode_one_at_a_time": "One at a Time", + "steering_mode_all": "All", + "temperature": "Temperature", + "temperature_hint": "LLM sampling temperature (0–2). Lower values are more deterministic. Leave empty for provider default.", + "max_media_size": "Max Media Size (bytes)", + "max_media_size_hint": "Maximum upload file size in bytes. Leave empty for the default (20 MB).", + "subturn_description": "Configure sub-turn spawning for background tasks and parallel agent work.", + "subturn_max_depth": "Max Depth", + "subturn_max_depth_hint": "Maximum nesting depth for spawned sub-turns. Values \u2264 0 use the default (3).", + "subturn_max_concurrent": "Max Concurrent", + "subturn_max_concurrent_hint": "Maximum number of sub-turns that can run in parallel.", + "subturn_default_timeout": "Default Timeout (minutes)", + "subturn_default_timeout_hint": "Default timeout in minutes for spawned sub-turns. Leave empty or set to 0 to use the system default (5 minutes).", + "subturn_token_budget": "Token Budget", + "subturn_token_budget_hint": "Default token budget for each sub-turn. Set to 0 for unlimited.", + "subturn_concurrency_timeout": "Concurrency Timeout (seconds)", + "subturn_concurrency_timeout_hint": "How long to wait for a concurrency slot before timing out.", + "routing_description": "Route simple tasks to a lighter model to save cost and latency.", + "routing_enabled": "Enable Smart Routing", + "routing_enabled_hint": "When enabled, simple requests are routed to a cheaper model while complex ones use the primary model.", + "routing_light_model": "Light Model", + "routing_light_model_hint": "Model name (from your model list) to use for simple tasks.", + "routing_threshold": "Complexity Threshold", + "routing_threshold_hint": "Complexity score threshold (0–1). Values \u2264 0 use the default (0.35). Requests scoring above this use the primary model.", + "filter_sensitive_data": "Filter Sensitive Data", + "filter_sensitive_data_hint": "Automatically filter API keys, tokens, and secrets from tool results before sending to the LLM.", + "filter_min_length": "Filter Min Content Length", + "filter_min_length_hint": "Content shorter than this (in characters) skips filtering for performance.", + "voice_echo_transcription": "Echo Voice Transcription", + "voice_echo_transcription_hint": "Echo the transcribed text back to the chat when processing voice messages.", + "gateway_log_level": "Log Level", + "gateway_log_level_hint": "Minimum log level for the gateway service.", + "log_level_debug": "Debug", + "log_level_info": "Info", + "log_level_warn": "Warn", + "log_level_error": "Error", + "log_level_fatal": "Fatal", "sections": { "agent": "Agent", "runtime": "Runtime", "exec": "Run Commands", "cron": "Cron Tasks", "launcher": "Service", - "devices": "Devices" + "devices": "Devices", + "subturn": "Sub-Turns", + "routing": "Smart Routing", + "tool_security": "Tool Security" }, "open_raw": "Raw Config", "back_to_visual": "Visual Config", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index a6c588807c..8d29e805d9 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -501,13 +501,57 @@ "allowed_cidrs": "允许访问网段", "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "allow_read_outside_workspace": "允许读取工作目录外文件", + "allow_read_outside_workspace_hint": "允许智能体读取工作目录之外的文件。", + "steering_mode": "转向模式", + "steering_mode_hint": "控制转向消息如何注入智能体循环。「逐条」模式逐条排队;「全部」模式一次性传递所有待处理消息。", + "steering_mode_one_at_a_time": "逐条", + "steering_mode_all": "全部", + "temperature": "温度", + "temperature_hint": "LLM 采样温度(0–2)。数值越低越确定性。留空使用提供商默认值。", + "max_media_size": "最大媒体大小(字节)", + "max_media_size_hint": "上传文件的最大字节数。留空使用默认值(20 MB)。", + "subturn_description": "配置子轮次的后台任务派生与并行工作。", + "subturn_max_depth": "最大深度", + "subturn_max_depth_hint": "派生子轮次的最大嵌套深度。设为 0 或留空将使用默认值(3)。", + "subturn_max_concurrent": "最大并发数", + "subturn_max_concurrent_hint": "可同时运行的子轮次最大数量。", + "subturn_default_timeout": "默认超时(分钟)", + "subturn_default_timeout_hint": "派生子轮次的默认超时时间(分钟)。留空或设为 0 使用系统默认值(5 分钟)。", + "subturn_token_budget": "Token 预算", + "subturn_token_budget_hint": "每个子轮次的默认 Token 预算。设为 0 表示不限制。", + "subturn_concurrency_timeout": "并发超时(秒)", + "subturn_concurrency_timeout_hint": "等待并发槽位释放的超时时间。", + "routing_description": "将简单任务路由到轻量模型以节省成本和降低延迟。", + "routing_enabled": "启用智能路由", + "routing_enabled_hint": "启用后,简单请求会路由到较便宜的模型,复杂请求使用主模型。", + "routing_light_model": "轻量模型", + "routing_light_model_hint": "用于简单任务的模型名称(来自模型列表)。", + "routing_threshold": "复杂度阈值", + "routing_threshold_hint": "复杂度评分阈值(0–1]。留空或设为 0 使用系统默认值(当前为 0.35)。评分高于此值的请求使用主模型。", + "filter_sensitive_data": "过滤敏感数据", + "filter_sensitive_data_hint": "自动过滤工具返回结果中的 API 密钥、令牌和机密信息,避免发送给 LLM。", + "filter_min_length": "过滤最小内容长度", + "filter_min_length_hint": "内容长度(字符数)低于此值时跳过过滤以提高性能。", + "voice_echo_transcription": "回显语音转录", + "voice_echo_transcription_hint": "处理语音消息时,将转录文本回显到聊天中。", + "gateway_log_level": "日志级别", + "gateway_log_level_hint": "网关服务的最低日志级别。", + "log_level_debug": "调试", + "log_level_info": "信息", + "log_level_warn": "警告", + "log_level_error": "错误", + "log_level_fatal": "致命", "sections": { "agent": "智能体", "runtime": "运行时", "exec": "运行命令", "cron": "定时任务", "launcher": "服务参数", - "devices": "设备" + "devices": "设备", + "subturn": "子轮次", + "routing": "智能路由", + "tool_security": "工具安全" }, "open_raw": "原始配置", "back_to_visual": "可视化配置",