picoclaw/web/frontend/src/components/config/form-model.ts
lxowalle 223ebdf0c7
docs: add evolution config controls (#2852)
* docs: add evolution config controls

* docs: address evolution config review
2026-05-12 11:23:06 +08:00

464 lines
13 KiB
TypeScript

export type JsonRecord = Record<string, unknown>
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
splitOnMarker: boolean
toolFeedbackEnabled: boolean
toolFeedbackMaxArgsLength: string
toolFeedbackSeparateMessages: boolean
execEnabled: boolean
allowRemote: boolean
enableDenyPatterns: boolean
customDenyPatternsText: string
customAllowPatternsText: string
execTimeoutSeconds: string
allowCommand: boolean
cronExecTimeoutMinutes: string
maxTokens: string
contextWindow: string
maxToolIterations: string
summarizeMessageThreshold: string
summarizeTokenPercent: string
dmScope: string
heartbeatEnabled: boolean
heartbeatInterval: string
devicesEnabled: boolean
monitorUSB: boolean
mcpEnabled: boolean
mcpDiscoveryEnabled: boolean
mcpDiscoveryTTL: string
mcpDiscoveryMaxSearchResults: string
mcpDiscoveryUseBM25: boolean
mcpDiscoveryUseRegex: boolean
mcpServers: MCPServerForm[]
evolutionEnabled: boolean
evolutionMode: string
evolutionStateDir: string
evolutionMinTaskCount: string
evolutionMinSuccessRatio: string
evolutionColdPathTrigger: string
evolutionColdPathTimesText: string
}
export type MCPServerType = "http" | "sse" | "stdio"
export interface MCPServerForm {
id: string
name: string
enabled: boolean
deferredOverride: boolean | null
type: MCPServerType
url: string
command: string
argsText: string
envText: string
envFile: string
headersText: string
}
export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
dashboardPassword: string
dashboardPasswordConfirm: string
}
export const DM_SCOPE_OPTIONS = [
{
value: "per-channel-peer",
labelKey: "pages.config.session_scope_per_channel_peer",
labelDefault: "Per Channel + Peer",
descKey: "pages.config.session_scope_per_channel_peer_desc",
descDefault: "Separate context for each user in each channel.",
},
{
value: "per-channel",
labelKey: "pages.config.session_scope_per_channel",
labelDefault: "Per Channel",
descKey: "pages.config.session_scope_per_channel_desc",
descDefault: "One shared context per channel.",
},
{
value: "per-peer",
labelKey: "pages.config.session_scope_per_peer",
labelDefault: "Per Peer",
descKey: "pages.config.session_scope_per_peer_desc",
descDefault: "One context per user across channels.",
},
{
value: "global",
labelKey: "pages.config.session_scope_global",
labelDefault: "Global",
descKey: "pages.config.session_scope_global_desc",
descDefault: "All messages share one global context.",
},
] as const
export const EMPTY_FORM: CoreConfigForm = {
workspace: "",
restrictToWorkspace: true,
splitOnMarker: false,
toolFeedbackEnabled: false,
toolFeedbackMaxArgsLength: "300",
toolFeedbackSeparateMessages: false,
execEnabled: true,
allowRemote: true,
enableDenyPatterns: true,
customDenyPatternsText: "",
customAllowPatternsText: "",
execTimeoutSeconds: "0",
allowCommand: true,
cronExecTimeoutMinutes: "5",
maxTokens: "32768",
contextWindow: "",
maxToolIterations: "50",
summarizeMessageThreshold: "20",
summarizeTokenPercent: "75",
dmScope: "per-channel-peer",
heartbeatEnabled: true,
heartbeatInterval: "30",
devicesEnabled: false,
monitorUSB: true,
mcpEnabled: false,
mcpDiscoveryEnabled: false,
mcpDiscoveryTTL: "5",
mcpDiscoveryMaxSearchResults: "5",
mcpDiscoveryUseBM25: true,
mcpDiscoveryUseRegex: false,
mcpServers: [],
evolutionEnabled: false,
evolutionMode: "observe",
evolutionStateDir: "",
evolutionMinTaskCount: "2",
evolutionMinSuccessRatio: "0.7",
evolutionColdPathTrigger: "after_turn",
evolutionColdPathTimesText: "",
}
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
function asRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord
}
return {}
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asBool(value: unknown): boolean {
return value === true
}
function asOptionalBool(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null
}
function asNumberString(value: unknown, fallback: string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
if (typeof value === "string" && value.trim() !== "") {
return value
}
return fallback
}
function toMCPServerType(value: unknown): MCPServerType {
if (value === "http" || value === "sse") {
return value
}
return "stdio"
}
function makeMCPServerID(name: string): string {
const encoded = encodeURIComponent(name)
if (encoded.length > 0) {
return `mcp-${encoded}`
}
return `mcp-${Math.random().toString(36).slice(2, 10)}`
}
function mapMCPServers(value: unknown): MCPServerForm[] {
const servers = asRecord(value)
return Object.entries(servers).map(([name, rawConfig]) => {
const cfg = asRecord(rawConfig)
const argsList = Array.isArray(cfg.args)
? cfg.args.filter((item): item is string => typeof item === "string")
: []
const url = asString(cfg.url)
const type =
cfg.type === undefined
? url
? "sse"
: "stdio"
: toMCPServerType(cfg.type)
const env = asRecord(cfg.env)
const headers = asRecord(cfg.headers)
return {
id: makeMCPServerID(name),
name,
enabled: cfg.enabled !== false,
deferredOverride: asOptionalBool(cfg.deferred),
type,
url,
command: asString(cfg.command),
argsText: argsList.join("\n"),
envText: JSON.stringify(env, null, 2),
envFile: asString(cfg.env_file),
headersText: JSON.stringify(headers, null, 2),
}
})
}
export function buildFormFromConfig(config: unknown): CoreConfigForm {
const root = asRecord(config)
const agents = asRecord(root.agents)
const defaults = asRecord(agents.defaults)
const session = asRecord(root.session)
const heartbeat = asRecord(root.heartbeat)
const devices = asRecord(root.devices)
const evolution = asRecord(root.evolution)
const tools = asRecord(root.tools)
const mcp = asRecord(tools.mcp)
const mcpDiscovery = asRecord(mcp.discovery)
const cron = asRecord(tools.cron)
const exec = asRecord(tools.exec)
const toolFeedback = asRecord(defaults.tool_feedback)
return {
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
restrictToWorkspace:
defaults.restrict_to_workspace === undefined
? EMPTY_FORM.restrictToWorkspace
: asBool(defaults.restrict_to_workspace),
splitOnMarker:
defaults.split_on_marker === undefined
? EMPTY_FORM.splitOnMarker
: asBool(defaults.split_on_marker),
toolFeedbackEnabled:
toolFeedback.enabled === undefined
? EMPTY_FORM.toolFeedbackEnabled
: asBool(toolFeedback.enabled),
toolFeedbackMaxArgsLength: asNumberString(
toolFeedback.max_args_length,
EMPTY_FORM.toolFeedbackMaxArgsLength,
),
toolFeedbackSeparateMessages:
toolFeedback.separate_messages === undefined
? EMPTY_FORM.toolFeedbackSeparateMessages
: asBool(toolFeedback.separate_messages),
execEnabled:
exec.enabled === undefined
? EMPTY_FORM.execEnabled
: asBool(exec.enabled),
allowRemote:
exec.allow_remote === undefined
? EMPTY_FORM.allowRemote
: asBool(exec.allow_remote),
enableDenyPatterns:
exec.enable_deny_patterns === undefined
? EMPTY_FORM.enableDenyPatterns
: asBool(exec.enable_deny_patterns),
customDenyPatternsText: Array.isArray(exec.custom_deny_patterns)
? exec.custom_deny_patterns
.filter((value): value is string => typeof value === "string")
.join("\n")
: EMPTY_FORM.customDenyPatternsText,
customAllowPatternsText: Array.isArray(exec.custom_allow_patterns)
? exec.custom_allow_patterns
.filter((value): value is string => typeof value === "string")
.join("\n")
: EMPTY_FORM.customAllowPatternsText,
execTimeoutSeconds: asNumberString(
exec.timeout_seconds,
EMPTY_FORM.execTimeoutSeconds,
),
allowCommand:
cron.allow_command === undefined
? EMPTY_FORM.allowCommand
: asBool(cron.allow_command),
cronExecTimeoutMinutes: asNumberString(
cron.exec_timeout_minutes,
EMPTY_FORM.cronExecTimeoutMinutes,
),
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
contextWindow: asNumberString(
defaults.context_window,
EMPTY_FORM.contextWindow,
),
maxToolIterations: asNumberString(
defaults.max_tool_iterations,
EMPTY_FORM.maxToolIterations,
),
summarizeMessageThreshold: asNumberString(
defaults.summarize_message_threshold,
EMPTY_FORM.summarizeMessageThreshold,
),
summarizeTokenPercent: asNumberString(
defaults.summarize_token_percent,
EMPTY_FORM.summarizeTokenPercent,
),
dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope,
heartbeatEnabled:
heartbeat.enabled === undefined
? EMPTY_FORM.heartbeatEnabled
: asBool(heartbeat.enabled),
heartbeatInterval: asNumberString(
heartbeat.interval,
EMPTY_FORM.heartbeatInterval,
),
devicesEnabled:
devices.enabled === undefined
? EMPTY_FORM.devicesEnabled
: asBool(devices.enabled),
monitorUSB:
devices.monitor_usb === undefined
? EMPTY_FORM.monitorUSB
: asBool(devices.monitor_usb),
mcpEnabled:
mcp.enabled === undefined ? EMPTY_FORM.mcpEnabled : asBool(mcp.enabled),
mcpDiscoveryEnabled:
mcpDiscovery.enabled === undefined
? EMPTY_FORM.mcpDiscoveryEnabled
: asBool(mcpDiscovery.enabled),
mcpDiscoveryTTL: asNumberString(
mcpDiscovery.ttl,
EMPTY_FORM.mcpDiscoveryTTL,
),
mcpDiscoveryMaxSearchResults: asNumberString(
mcpDiscovery.max_search_results,
EMPTY_FORM.mcpDiscoveryMaxSearchResults,
),
mcpDiscoveryUseBM25:
mcpDiscovery.use_bm25 === undefined
? EMPTY_FORM.mcpDiscoveryUseBM25
: asBool(mcpDiscovery.use_bm25),
mcpDiscoveryUseRegex:
mcpDiscovery.use_regex === undefined
? EMPTY_FORM.mcpDiscoveryUseRegex
: asBool(mcpDiscovery.use_regex),
mcpServers: mapMCPServers(mcp.servers),
evolutionEnabled:
evolution.enabled === undefined
? EMPTY_FORM.evolutionEnabled
: asBool(evolution.enabled),
evolutionMode: asString(evolution.mode) || EMPTY_FORM.evolutionMode,
evolutionStateDir:
asString(evolution.state_dir) || EMPTY_FORM.evolutionStateDir,
evolutionMinTaskCount: asNumberString(
evolution.min_task_count,
EMPTY_FORM.evolutionMinTaskCount,
),
evolutionMinSuccessRatio: asNumberString(
evolution.min_success_ratio,
EMPTY_FORM.evolutionMinSuccessRatio,
),
evolutionColdPathTrigger:
asString(evolution.cold_path_trigger) ||
EMPTY_FORM.evolutionColdPathTrigger,
evolutionColdPathTimesText: Array.isArray(evolution.cold_path_times)
? evolution.cold_path_times
.filter((value): value is string => typeof value === "string")
.join("\n")
: EMPTY_FORM.evolutionColdPathTimesText,
}
}
export function parseIntField(
rawValue: string,
label: string,
options: { min?: number; max?: number } = {},
): number {
const value = Number(rawValue)
if (!Number.isInteger(value)) {
throw new Error(`${label} must be an integer.`)
}
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 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 []
}
return raw
.split(/[\n,]/)
.map((v) => v.trim())
.filter((v) => v.length > 0)
}
export function parseMultilineList(raw: string): string[] {
if (!raw.trim()) {
return []
}
return raw
.split("\n")
.map((value) => value.trim())
.filter((value) => value.length > 0)
}
export function parseJSONObjectField(
rawValue: string,
label: string,
): Record<string, string> {
const trimmed = rawValue.trim()
if (trimmed === "") {
return {}
}
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
throw new Error(`${label} must be valid JSON.`)
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${label} must be a JSON object.`)
}
const entries = Object.entries(parsed as Record<string, unknown>)
const result: Record<string, string> = {}
for (const [key, value] of entries) {
if (typeof value !== "string") {
throw new Error(`${label}.${key} must be a string.`)
}
result[key] = value
}
return result
}