From 2a68bcbeb32e2fa14282639ca17574d48a21fe2d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 2 Feb 2026 21:31:17 -0500 Subject: [PATCH] feat(ui): add Agents dashboard --- CHANGELOG.md | 1 + src/agents/agent-scope.ts | 14 + src/auto-reply/reply/get-reply.ts | 42 +- src/commands/agent.ts | 3 + src/config/schema.ts | 6 + src/config/types.agents.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/gateway/assistant-identity.ts | 39 +- src/gateway/protocol/index.ts | 37 + src/gateway/protocol/schema/agent.ts | 1 + .../protocol/schema/agents-models-skills.ts | 71 +- .../protocol/schema/protocol-schemas.ts | 14 + src/gateway/protocol/schema/types.ts | 14 + src/gateway/server-methods-list.ts | 3 + src/gateway/server-methods/agents.ts | 254 +++ src/gateway/server-methods/skills.ts | 22 +- ui/src/styles/components.css | 396 ++++ ui/src/styles/layout.css | 20 +- ui/src/ui/app-render.ts | 358 +++ ui/src/ui/app-settings.ts | 25 + ui/src/ui/app-view-state.ts | 18 + ui/src/ui/app.ts | 20 + ui/src/ui/components/resizable-divider.ts | 10 +- ui/src/ui/controllers/agent-files.ts | 114 + ui/src/ui/controllers/agent-identity.ts | 59 + ui/src/ui/controllers/agent-skills.ts | 33 + ui/src/ui/controllers/agents.ts | 6 + ui/src/ui/navigation.ts | 10 +- ui/src/ui/types.ts | 35 + ui/src/ui/views/agents.ts | 1950 +++++++++++++++++ ui/src/ui/views/channels.config.ts | 41 + ui/src/ui/views/skills.ts | 54 +- 32 files changed, 3652 insertions(+), 21 deletions(-) create mode 100644 ui/src/ui/controllers/agent-files.ts create mode 100644 ui/src/ui/controllers/agent-identity.ts create mode 100644 ui/src/ui/controllers/agent-skills.ts create mode 100644 ui/src/ui/views/agents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c024ecb1fe..ecbad82bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs. - Docs: seed zh-CN translations. (#6619) Thanks @joshp123. - Docs: expand zh-Hans navigation and fix zh-CN index asset paths. (#7242) Thanks @joshp123. - Docs: add zh-CN landing notice + AI-translated image. (#7303) Thanks @joshp123. diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 57237c4de5..a8096da74d 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -19,6 +19,7 @@ type ResolvedAgentConfig = { workspace?: string; agentDir?: string; model?: AgentEntry["model"]; + skills?: AgentEntry["skills"]; memorySearch?: AgentEntry["memorySearch"]; humanDelay?: AgentEntry["humanDelay"]; heartbeat?: AgentEntry["heartbeat"]; @@ -112,6 +113,7 @@ export function resolveAgentConfig( typeof entry.model === "string" || (entry.model && typeof entry.model === "object") ? entry.model : undefined, + skills: Array.isArray(entry.skills) ? entry.skills : undefined, memorySearch: entry.memorySearch, humanDelay: entry.humanDelay, heartbeat: entry.heartbeat, @@ -123,6 +125,18 @@ export function resolveAgentConfig( }; } +export function resolveAgentSkillsFilter( + cfg: OpenClawConfig, + agentId: string, +): string[] | undefined { + const raw = resolveAgentConfig(cfg, agentId)?.skills; + if (!raw) { + return undefined; + } + const normalized = raw.map((entry) => String(entry).trim()).filter(Boolean); + return normalized.length > 0 ? normalized : []; +} + export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined { const raw = resolveAgentConfig(cfg, agentId)?.model; if (!raw) { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index c5cccf4b83..7066b4538b 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -4,6 +4,7 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId, + resolveAgentSkillsFilter, } from "../../agents/agent-scope.js"; import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; @@ -24,6 +25,31 @@ import { initSessionState } from "./session.js"; import { stageSandboxMedia } from "./stage-sandbox-media.js"; import { createTypingController } from "./typing.js"; +function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined { + const normalize = (list?: string[]) => { + if (!Array.isArray(list)) { + return undefined; + } + return list.map((entry) => String(entry).trim()).filter(Boolean); + }; + const channel = normalize(channelFilter); + const agent = normalize(agentFilter); + if (!channel && !agent) { + return undefined; + } + if (!channel) { + return agent; + } + if (!agent) { + return channel; + } + if (channel.length === 0 || agent.length === 0) { + return []; + } + const agentSet = new Set(agent); + return channel.filter((name) => agentSet.has(name)); +} + export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, @@ -38,6 +64,12 @@ export async function getReplyFromConfig( sessionKey: agentSessionKey, config: cfg, }); + const mergedSkillFilter = mergeSkillFilters( + opts?.skillFilter, + resolveAgentSkillsFilter(cfg, agentId), + ); + const resolvedOpts = + mergedSkillFilter !== undefined ? { ...opts, skillFilter: mergedSkillFilter } : opts; const agentCfg = cfg.agents?.defaults; const sessionCfg = cfg.session; const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ @@ -164,8 +196,8 @@ export async function getReplyFromConfig( provider, model, typing, - opts, - skillFilter: opts?.skillFilter, + opts: resolvedOpts, + skillFilter: mergedSkillFilter, }); if (directiveResult.kind === "reply") { return directiveResult.reply; @@ -216,7 +248,7 @@ export async function getReplyFromConfig( sessionScope, workspaceDir, isGroup, - opts, + opts: resolvedOpts, typing, allowTextCommands, inlineStatusRequested, @@ -238,7 +270,7 @@ export async function getReplyFromConfig( contextTokens, directiveAck, abortedLastRun, - skillFilter: opts?.skillFilter, + skillFilter: mergedSkillFilter, }); if (inlineActionResult.kind === "reply") { return inlineActionResult.reply; @@ -284,7 +316,7 @@ export async function getReplyFromConfig( perMessageQueueMode, perMessageQueueOptions, typing, - opts, + opts: resolvedOpts, defaultProvider, defaultModel, timeoutMs, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e8818495b3..20d3b6c89c 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -4,6 +4,7 @@ import { resolveAgentDir, resolveAgentModelFallbacksOverride, resolveAgentModelPrimary, + resolveAgentSkillsFilter, resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; @@ -187,11 +188,13 @@ export async function agentCommand( const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); + const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId); const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: skillsSnapshotVersion, + skillFilter, }) : sessionEntry?.skillsSnapshot; diff --git a/src/config/schema.ts b/src/config/schema.ts index a151b52da1..eaf983c977 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -124,6 +124,7 @@ const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -346,6 +347,7 @@ const FIELD_LABELS: Record = { "channels.mattermost.requireMention": "Mattermost Require Mention", "channels.signal.account": "Signal Account", "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", "agents.list[].identity.avatar": "Agent Avatar", "discovery.mdns.mode": "mDNS Discovery Mode", "plugins.enabled": "Enable Plugins", @@ -377,6 +379,10 @@ const FIELD_HELP: Record = { "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list.*.skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "discovery.mdns.mode": diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index f083c18979..b6cb71bf58 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -24,6 +24,8 @@ export type AgentConfig = { workspace?: string; agentDir?: string; model?: AgentModelConfig; + /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ + skills?: string[]; memorySearch?: MemorySearchConfig; /** Human-like delay between block replies for this agent. */ humanDelay?: HumanDelayConfig; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index bab9748f63..1314397e31 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -427,6 +427,7 @@ export const AgentEntrySchema = z workspace: z.string().optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), + skills: z.array(z.string()).optional(), memorySearch: MemorySearchSchema, humanDelay: HumanDelaySchema.optional(), heartbeat: HeartbeatSchema, diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index 52c4cc7c39..e446171087 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -6,6 +6,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_AVATAR = 200; +const MAX_ASSISTANT_EMOJI = 16; export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = { agentId: "main", @@ -17,6 +18,7 @@ export type AssistantIdentity = { agentId: string; name: string; avatar: string; + emoji?: string; }; function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { @@ -64,6 +66,33 @@ function normalizeAvatarValue(value: string | undefined): string | undefined { return undefined; } +function normalizeEmojiValue(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.length > MAX_ASSISTANT_EMOJI) { + return undefined; + } + let hasNonAscii = false; + for (let i = 0; i < trimmed.length; i += 1) { + if (trimmed.charCodeAt(i) > 127) { + hasNonAscii = true; + break; + } + } + if (!hasNonAscii) { + return undefined; + } + if (isAvatarUrl(trimmed) || looksLikeAvatarPath(trimmed)) { + return undefined; + } + return trimmed; +} + export function resolveAssistantIdentity(params: { cfg: OpenClawConfig; agentId?: string | null; @@ -92,5 +121,13 @@ export function resolveAssistantIdentity(params: { avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ?? DEFAULT_ASSISTANT_IDENTITY.avatar; - return { agentId, name, avatar }; + const emojiCandidates = [ + coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_EMOJI), + coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_EMOJI), + coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_EMOJI), + coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_EMOJI), + ]; + const emoji = emojiCandidates.map((candidate) => normalizeEmojiValue(candidate)).find(Boolean); + + return { agentId, name, avatar, emoji }; } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 9bd0b40543..7398ef42a4 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -9,6 +9,20 @@ import { AgentParamsSchema, type AgentSummary, AgentSummarySchema, + type AgentsFileEntry, + AgentsFileEntrySchema, + type AgentsFilesGetParams, + AgentsFilesGetParamsSchema, + type AgentsFilesGetResult, + AgentsFilesGetResultSchema, + type AgentsFilesListParams, + AgentsFilesListParamsSchema, + type AgentsFilesListResult, + AgentsFilesListResultSchema, + type AgentsFilesSetParams, + AgentsFilesSetParamsSchema, + type AgentsFilesSetResult, + AgentsFilesSetResultSchema, type AgentsListParams, AgentsListParamsSchema, type AgentsListResult, @@ -209,6 +223,15 @@ export const validateAgentIdentityParams = export const validateAgentWaitParams = ajv.compile(AgentWaitParamsSchema); export const validateWakeParams = ajv.compile(WakeParamsSchema); export const validateAgentsListParams = ajv.compile(AgentsListParamsSchema); +export const validateAgentsFilesListParams = ajv.compile( + AgentsFilesListParamsSchema, +); +export const validateAgentsFilesGetParams = ajv.compile( + AgentsFilesGetParamsSchema, +); +export const validateAgentsFilesSetParams = ajv.compile( + AgentsFilesSetParamsSchema, +); export const validateNodePairRequestParams = ajv.compile( NodePairRequestParamsSchema, ); @@ -408,6 +431,13 @@ export { WebLoginStartParamsSchema, WebLoginWaitParamsSchema, AgentSummarySchema, + AgentsFileEntrySchema, + AgentsFilesListParamsSchema, + AgentsFilesListResultSchema, + AgentsFilesGetParamsSchema, + AgentsFilesGetResultSchema, + AgentsFilesSetParamsSchema, + AgentsFilesSetResultSchema, AgentsListParamsSchema, AgentsListResultSchema, ModelsListParamsSchema, @@ -482,6 +512,13 @@ export type { WebLoginStartParams, WebLoginWaitParams, AgentSummary, + AgentsFileEntry, + AgentsFilesListParams, + AgentsFilesListResult, + AgentsFilesGetParams, + AgentsFilesGetResult, + AgentsFilesSetParams, + AgentsFilesSetResult, AgentsListParams, AgentsListResult, SkillsStatusParams, diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 9e11a63415..3d6123df63 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -84,6 +84,7 @@ export const AgentIdentityResultSchema = Type.Object( agentId: NonEmptyString, name: Type.Optional(NonEmptyString), avatar: Type.Optional(NonEmptyString), + emoji: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 564fa68a2b..d1230d8d41 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -44,6 +44,70 @@ export const AgentsListResultSchema = Type.Object( { additionalProperties: false }, ); +export const AgentsFileEntrySchema = Type.Object( + { + name: NonEmptyString, + path: NonEmptyString, + missing: Type.Boolean(), + size: Type.Optional(Type.Integer({ minimum: 0 })), + updatedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + content: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +export const AgentsFilesListParamsSchema = Type.Object( + { + agentId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const AgentsFilesListResultSchema = Type.Object( + { + agentId: NonEmptyString, + workspace: NonEmptyString, + files: Type.Array(AgentsFileEntrySchema), + }, + { additionalProperties: false }, +); + +export const AgentsFilesGetParamsSchema = Type.Object( + { + agentId: NonEmptyString, + name: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const AgentsFilesGetResultSchema = Type.Object( + { + agentId: NonEmptyString, + workspace: NonEmptyString, + file: AgentsFileEntrySchema, + }, + { additionalProperties: false }, +); + +export const AgentsFilesSetParamsSchema = Type.Object( + { + agentId: NonEmptyString, + name: NonEmptyString, + content: Type.String(), + }, + { additionalProperties: false }, +); + +export const AgentsFilesSetResultSchema = Type.Object( + { + ok: Type.Literal(true), + agentId: NonEmptyString, + workspace: NonEmptyString, + file: AgentsFileEntrySchema, + }, + { additionalProperties: false }, +); + export const ModelsListParamsSchema = Type.Object({}, { additionalProperties: false }); export const ModelsListResultSchema = Type.Object( @@ -53,7 +117,12 @@ export const ModelsListResultSchema = Type.Object( { additionalProperties: false }, ); -export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false }); +export const SkillsStatusParamsSchema = Type.Object( + { + agentId: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false }); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 11eb6e2ba9..87d87d03bc 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -11,6 +11,13 @@ import { } from "./agent.js"; import { AgentSummarySchema, + AgentsFileEntrySchema, + AgentsFilesGetParamsSchema, + AgentsFilesGetResultSchema, + AgentsFilesListParamsSchema, + AgentsFilesListResultSchema, + AgentsFilesSetParamsSchema, + AgentsFilesSetResultSchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, @@ -182,6 +189,13 @@ export const ProtocolSchemas: Record = { WebLoginStartParams: WebLoginStartParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema, AgentSummary: AgentSummarySchema, + AgentsFileEntry: AgentsFileEntrySchema, + AgentsFilesListParams: AgentsFilesListParamsSchema, + AgentsFilesListResult: AgentsFilesListResultSchema, + AgentsFilesGetParams: AgentsFilesGetParamsSchema, + AgentsFilesGetResult: AgentsFilesGetResultSchema, + AgentsFilesSetParams: AgentsFilesSetParamsSchema, + AgentsFilesSetResult: AgentsFilesSetResultSchema, AgentsListParams: AgentsListParamsSchema, AgentsListResult: AgentsListResultSchema, ModelChoice: ModelChoiceSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 193784bc81..6bc9bff5e2 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -9,6 +9,13 @@ import type { } from "./agent.js"; import type { AgentSummarySchema, + AgentsFileEntrySchema, + AgentsFilesGetParamsSchema, + AgentsFilesGetResultSchema, + AgentsFilesListParamsSchema, + AgentsFilesListResultSchema, + AgentsFilesSetParamsSchema, + AgentsFilesSetResultSchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, @@ -171,6 +178,13 @@ export type ChannelsLogoutParams = Static; export type WebLoginStartParams = Static; export type WebLoginWaitParams = Static; export type AgentSummary = Static; +export type AgentsFileEntry = Static; +export type AgentsFilesListParams = Static; +export type AgentsFilesListResult = Static; +export type AgentsFilesGetParams = Static; +export type AgentsFilesGetResult = Static; +export type AgentsFilesSetParams = Static; +export type AgentsFilesSetResult = Static; export type AgentsListParams = Static; export type AgentsListResult = Static; export type ModelChoice = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 098384d443..4f997c2dfb 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -32,6 +32,9 @@ const BASE_METHODS = [ "talk.mode", "models.list", "agents.list", + "agents.files.list", + "agents.files.get", + "agents.files.set", "skills.status", "skills.bins", "skills.install", diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index a700e495ed..1254eac6e0 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -1,13 +1,128 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import type { GatewayRequestHandlers } from "./types.js"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { + DEFAULT_AGENTS_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_MEMORY_ALT_FILENAME, + DEFAULT_MEMORY_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_USER_FILENAME, +} from "../../agents/workspace.js"; import { loadConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { ErrorCodes, errorShape, formatValidationErrors, + validateAgentsFilesGetParams, + validateAgentsFilesListParams, + validateAgentsFilesSetParams, validateAgentsListParams, } from "../protocol/index.js"; import { listAgentsForGateway } from "../session-utils.js"; +const BOOTSTRAP_FILE_NAMES = [ + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, +] as const; + +const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; + +const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); + +type FileMeta = { + size: number; + updatedAtMs: number; +}; + +async function statFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return null; + } + return { + size: stat.size, + updatedAtMs: Math.floor(stat.mtimeMs), + }; + } catch { + return null; + } +} + +async function listAgentFiles(workspaceDir: string) { + const files: Array<{ + name: string; + path: string; + missing: boolean; + size?: number; + updatedAtMs?: number; + }> = []; + + for (const name of BOOTSTRAP_FILE_NAMES) { + const filePath = path.join(workspaceDir, name); + const meta = await statFile(filePath); + if (meta) { + files.push({ + name, + path: filePath, + missing: false, + size: meta.size, + updatedAtMs: meta.updatedAtMs, + }); + } else { + files.push({ name, path: filePath, missing: true }); + } + } + + const primaryMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME); + const primaryMeta = await statFile(primaryMemoryPath); + if (primaryMeta) { + files.push({ + name: DEFAULT_MEMORY_FILENAME, + path: primaryMemoryPath, + missing: false, + size: primaryMeta.size, + updatedAtMs: primaryMeta.updatedAtMs, + }); + } else { + const altMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME); + const altMeta = await statFile(altMemoryPath); + if (altMeta) { + files.push({ + name: DEFAULT_MEMORY_ALT_FILENAME, + path: altMemoryPath, + missing: false, + size: altMeta.size, + updatedAtMs: altMeta.updatedAtMs, + }); + } else { + files.push({ name: DEFAULT_MEMORY_FILENAME, path: primaryMemoryPath, missing: true }); + } + } + + return files; +} + +function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType) { + const agentId = normalizeAgentId(agentIdRaw); + const allowed = new Set(listAgentIds(cfg)); + if (!allowed.has(agentId)) { + return null; + } + return agentId; +} + export const agentsHandlers: GatewayRequestHandlers = { "agents.list": ({ params, respond }) => { if (!validateAgentsListParams(params)) { @@ -26,4 +141,143 @@ export const agentsHandlers: GatewayRequestHandlers = { const result = listAgentsForGateway(cfg); respond(true, result, undefined); }, + "agents.files.list": async ({ params, respond }) => { + if (!validateAgentsFilesListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid agents.files.list params: ${formatValidationErrors( + validateAgentsFilesListParams.errors, + )}`, + ), + ); + return; + } + const cfg = loadConfig(); + const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); + if (!agentId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); + return; + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const files = await listAgentFiles(workspaceDir); + respond(true, { agentId, workspace: workspaceDir, files }, undefined); + }, + "agents.files.get": async ({ params, respond }) => { + if (!validateAgentsFilesGetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid agents.files.get params: ${formatValidationErrors( + validateAgentsFilesGetParams.errors, + )}`, + ), + ); + return; + } + const cfg = loadConfig(); + const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); + if (!agentId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); + return; + } + const name = String(params.name ?? "").trim(); + if (!ALLOWED_FILE_NAMES.has(name)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`), + ); + return; + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const filePath = path.join(workspaceDir, name); + const meta = await statFile(filePath); + if (!meta) { + respond( + true, + { + agentId, + workspace: workspaceDir, + file: { name, path: filePath, missing: true }, + }, + undefined, + ); + return; + } + const content = await fs.readFile(filePath, "utf-8"); + respond( + true, + { + agentId, + workspace: workspaceDir, + file: { + name, + path: filePath, + missing: false, + size: meta.size, + updatedAtMs: meta.updatedAtMs, + content, + }, + }, + undefined, + ); + }, + "agents.files.set": async ({ params, respond }) => { + if (!validateAgentsFilesSetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid agents.files.set params: ${formatValidationErrors( + validateAgentsFilesSetParams.errors, + )}`, + ), + ); + return; + } + const cfg = loadConfig(); + const agentId = resolveAgentIdOrError(String(params.agentId ?? ""), cfg); + if (!agentId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); + return; + } + const name = String(params.name ?? "").trim(); + if (!ALLOWED_FILE_NAMES.has(name)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`), + ); + return; + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + await fs.mkdir(workspaceDir, { recursive: true }); + const filePath = path.join(workspaceDir, name); + const content = String(params.content ?? ""); + await fs.writeFile(filePath, content, "utf-8"); + const meta = await statFile(filePath); + respond( + true, + { + ok: true, + agentId, + workspace: workspaceDir, + file: { + name, + path: filePath, + missing: false, + size: meta?.size, + updatedAtMs: meta?.updatedAtMs, + content, + }, + }, + undefined, + ); + }, }; diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 0bdda27f8f..ff829274e0 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -1,11 +1,16 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { GatewayRequestHandlers } from "./types.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + listAgentIds, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { ErrorCodes, errorShape, @@ -75,7 +80,20 @@ export const skillsHandlers: GatewayRequestHandlers = { return; } const cfg = loadConfig(); - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const agentIdRaw = typeof params?.agentId === "string" ? params.agentId.trim() : ""; + const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg); + if (agentIdRaw) { + const knownAgents = listAgentIds(cfg); + if (!knownAgents.includes(agentId)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent id "${agentIdRaw}"`), + ); + return; + } + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg, eligibility: { remote: getRemoteSkillEligibility() }, diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 848ffc365e..718e3daed7 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -989,6 +989,7 @@ white-space: pre-wrap; overflow: hidden; display: -webkit-box; + line-clamp: 3; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } @@ -1496,3 +1497,398 @@ flex-wrap: wrap; gap: 8px; } + +/* =========================================== + Agents + =========================================== */ + +.agents-layout { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 16px; +} + +.agents-sidebar { + display: grid; + gap: 12px; + align-self: start; +} + +.agents-main { + display: grid; + gap: 16px; +} + +.agent-list { + display: grid; + gap: 8px; +} + +.agent-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + padding: 10px 12px; + cursor: pointer; + transition: border-color var(--duration-fast) ease; +} + +.agent-row:hover { + border-color: var(--border-strong); +} + +.agent-row.active { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.agent-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--secondary); + display: grid; + place-items: center; + font-weight: 600; +} + +.agent-avatar--lg { + width: 48px; + height: 48px; + font-size: 20px; +} + +.agent-info { + display: grid; + gap: 2px; + min-width: 0; +} + +.agent-title { + font-weight: 600; +} + +.agent-sub { + color: var(--muted); + font-size: 12px; +} + +.agent-pill { + border: 1px solid var(--border); + border-radius: var(--radius-full); + padding: 4px 10px; + font-size: 11px; + color: var(--muted); + background: var(--secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.agent-pill.warn { + color: var(--warn); + border-color: var(--warn); +} + +.agent-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; +} + +.agent-header-main { + display: flex; + gap: 16px; + align-items: center; +} + +.agent-header-meta { + display: grid; + justify-items: end; + gap: 6px; + color: var(--muted); +} + +.agent-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.agent-tab { + border: 1px solid var(--border); + border-radius: var(--radius-full); + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + background: var(--secondary); + cursor: pointer; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease; +} + +.agent-tab.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.agents-overview-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.agent-kv { + display: grid; + gap: 6px; +} + +.agent-kv-sub { + font-size: 12px; +} + +.agent-model-select { + display: grid; + gap: 12px; +} + +.agent-model-meta { + display: grid; + gap: 6px; + min-width: 200px; +} + +.agent-files-grid { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 16px; +} + +.agent-files-list { + display: grid; + gap: 8px; +} + +.agent-file-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + padding: 10px 12px; + cursor: pointer; + transition: border-color var(--duration-fast) ease; +} + +.agent-file-row:hover { + border-color: var(--border-strong); +} + +.agent-file-row.active { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.agent-file-name { + font-weight: 600; +} + +.agent-file-meta { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.agent-files-editor { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + background: var(--card); +} + +.agent-file-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.agent-file-title { + font-weight: 600; +} + +.agent-file-sub { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.agent-file-actions { + display: flex; + gap: 8px; +} + +.agent-tools-meta { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.agent-tools-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.agent-tools-grid { + display: grid; + gap: 16px; +} + +.agent-tools-section { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px; + background: var(--bg-elevated); +} + +.agent-tools-header { + font-weight: 600; + margin-bottom: 10px; +} + +.agent-tools-list { + display: grid; + gap: 8px 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.agent-tool-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.agent-tool-title { + font-weight: 600; + font-size: 13px; +} + +.agent-tool-sub { + color: var(--muted); + font-size: 11px; + margin-top: 2px; +} + +.agent-skills-groups { + display: grid; + gap: 16px; +} + +.agent-skills-group { + display: grid; + gap: 10px; +} + +.agent-skills-group summary { + list-style: none; +} + +.agent-skills-header { + display: flex; + align-items: center; + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + cursor: pointer; + gap: 8px; +} + +.agent-skills-header > span:last-child { + margin-left: auto; +} + +.agent-skills-group summary::-webkit-details-marker { + display: none; +} + +.agent-skills-group summary::marker { + content: ""; +} + +.agent-skills-header::after { + content: "▸"; + font-size: 12px; + color: var(--muted); + transition: transform var(--duration-fast) ease; + margin-left: 8px; +} + +.agent-skills-group[open] .agent-skills-header::after { + transform: rotate(90deg); +} + +.agent-skill-row { + align-items: flex-start; + gap: 18px; +} + +.agent-skill-row .list-meta { + display: flex; + align-items: flex-start; + justify-content: flex-end; + min-width: auto; +} + +.skills-grid { + grid-template-columns: 1fr; +} + +@container (min-width: 900px) { + .skills-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 980px) { + .agents-layout { + grid-template-columns: 1fr; + } + + .agent-header { + grid-template-columns: 1fr; + } + + .agent-header-meta { + justify-items: start; + } + + .agent-files-grid { + grid-template-columns: 1fr; + } + + .agent-tools-list { + grid-template-columns: 1fr; + } +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index c2a5c6fe3f..b939c27c29 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -62,7 +62,10 @@ .shell--chat-focus .content { padding-top: 0; - gap: 0; +} + +.shell--chat-focus .content > * + * { + margin-top: 0; } /* =========================================== @@ -418,23 +421,32 @@ .content { grid-area: content; padding: 12px 16px 32px; - display: flex; - flex-direction: column; - gap: 24px; + display: block; min-height: 0; overflow-y: auto; overflow-x: hidden; } +.content > * + * { + margin-top: 24px; +} + :root[data-theme="light"] .content { background: var(--bg-content); } .content--chat { + display: flex; + flex-direction: column; + gap: 24px; overflow: hidden; padding-bottom: 0; } +.content--chat > * + * { + margin-top: 0; +} + /* Content header */ .content-header { display: flex; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2312040dab..92db25f9f7 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -3,6 +3,10 @@ import type { AppViewState } from "./app-view-state"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { refreshChatAvatar } from "./app-chat"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; +import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files"; +import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity"; +import { loadAgentSkills } from "./controllers/agent-skills"; +import { loadAgents } from "./controllers/agents"; import { loadChannels } from "./controllers/channels"; import { loadChatHistory } from "./controllers/chat"; import { @@ -47,6 +51,7 @@ import { } from "./controllers/skills"; import { icons } from "./icons"; import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation"; +import { renderAgents } from "./views/agents"; import { renderChannels } from "./views/channels"; import { renderChat } from "./views/chat"; import { renderConfig } from "./views/config"; @@ -90,6 +95,13 @@ export function renderApp(state: AppViewState) { const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; + const configValue = + state.configForm ?? (state.configSnapshot?.config as Record | null); + const resolvedAgentId = + state.agentsSelectedId ?? + state.agentsList?.defaultId ?? + state.agentsList?.agents?.[0]?.id ?? + null; return html`
@@ -317,6 +329,352 @@ export function renderApp(state: AppViewState) { : nothing } + ${ + state.tab === "agents" + ? renderAgents({ + loading: state.agentsLoading, + error: state.agentsError, + agentsList: state.agentsList, + selectedAgentId: resolvedAgentId, + activePanel: state.agentsPanel, + configForm: configValue, + configLoading: state.configLoading, + configSaving: state.configSaving, + configDirty: state.configFormDirty, + channelsLoading: state.channelsLoading, + channelsError: state.channelsError, + channelsSnapshot: state.channelsSnapshot, + channelsLastSuccess: state.channelsLastSuccess, + cronLoading: state.cronLoading, + cronStatus: state.cronStatus, + cronJobs: state.cronJobs, + cronError: state.cronError, + agentFilesLoading: state.agentFilesLoading, + agentFilesError: state.agentFilesError, + agentFilesList: state.agentFilesList, + agentFileActive: state.agentFileActive, + agentFileContents: state.agentFileContents, + agentFileDrafts: state.agentFileDrafts, + agentFileSaving: state.agentFileSaving, + agentIdentityLoading: state.agentIdentityLoading, + agentIdentityError: state.agentIdentityError, + agentIdentityById: state.agentIdentityById, + agentSkillsLoading: state.agentSkillsLoading, + agentSkillsReport: state.agentSkillsReport, + agentSkillsError: state.agentSkillsError, + agentSkillsAgentId: state.agentSkillsAgentId, + skillsFilter: state.skillsFilter, + onRefresh: async () => { + await loadAgents(state); + const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; + if (agentIds.length > 0) { + void loadAgentIdentities(state, agentIds); + } + }, + onSelectAgent: (agentId) => { + if (state.agentsSelectedId === agentId) { + return; + } + state.agentsSelectedId = agentId; + state.agentFilesList = null; + state.agentFilesError = null; + state.agentFilesLoading = false; + state.agentFileActive = null; + state.agentFileContents = {}; + state.agentFileDrafts = {}; + state.agentSkillsReport = null; + state.agentSkillsError = null; + state.agentSkillsAgentId = null; + void loadAgentIdentity(state, agentId); + if (state.agentsPanel === "files") { + void loadAgentFiles(state, agentId); + } + if (state.agentsPanel === "skills") { + void loadAgentSkills(state, agentId); + } + }, + onSelectPanel: (panel) => { + state.agentsPanel = panel; + if (panel === "files" && resolvedAgentId) { + if (state.agentFilesList?.agentId !== resolvedAgentId) { + state.agentFilesList = null; + state.agentFilesError = null; + state.agentFileActive = null; + state.agentFileContents = {}; + state.agentFileDrafts = {}; + void loadAgentFiles(state, resolvedAgentId); + } + } + if (panel === "skills") { + if (resolvedAgentId) { + void loadAgentSkills(state, resolvedAgentId); + } + } + if (panel === "channels") { + void loadChannels(state, false); + } + if (panel === "cron") { + void state.loadCron(); + } + }, + onLoadFiles: (agentId) => loadAgentFiles(state, agentId), + onSelectFile: (name) => { + state.agentFileActive = name; + if (!resolvedAgentId) { + return; + } + void loadAgentFileContent(state, resolvedAgentId, name); + }, + onFileDraftChange: (name, content) => { + state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content }; + }, + onFileReset: (name) => { + const base = state.agentFileContents[name] ?? ""; + state.agentFileDrafts = { ...state.agentFileDrafts, [name]: base }; + }, + onFileSave: (name) => { + if (!resolvedAgentId) { + return; + } + const content = + state.agentFileDrafts[name] ?? state.agentFileContents[name] ?? ""; + void saveAgentFile(state, resolvedAgentId, name, content); + }, + onToolsProfileChange: (agentId, profile, clearAllow) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + const basePath = ["agents", "list", index, "tools"]; + if (profile) { + updateConfigFormValue(state, [...basePath, "profile"], profile); + } else { + removeConfigFormValue(state, [...basePath, "profile"]); + } + if (clearAllow) { + removeConfigFormValue(state, [...basePath, "allow"]); + } + }, + onToolsOverridesChange: (agentId, alsoAllow, deny) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + const basePath = ["agents", "list", index, "tools"]; + if (alsoAllow.length > 0) { + updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow); + } else { + removeConfigFormValue(state, [...basePath, "alsoAllow"]); + } + if (deny.length > 0) { + updateConfigFormValue(state, [...basePath, "deny"], deny); + } else { + removeConfigFormValue(state, [...basePath, "deny"]); + } + }, + onConfigReload: () => loadConfig(state), + onConfigSave: () => saveConfig(state), + onChannelsRefresh: () => loadChannels(state, false), + onCronRefresh: () => state.loadCron(), + onSkillsFilterChange: (next) => (state.skillsFilter = next), + onSkillsRefresh: () => { + if (resolvedAgentId) { + void loadAgentSkills(state, resolvedAgentId); + } + }, + onAgentSkillToggle: (agentId, skillName, enabled) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + const entry = list[index] as { skills?: unknown }; + const normalizedSkill = skillName.trim(); + if (!normalizedSkill) { + return; + } + const allSkills = + state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ?? + []; + const existing = Array.isArray(entry.skills) + ? entry.skills.map((name) => String(name).trim()).filter(Boolean) + : undefined; + const base = existing ?? allSkills; + const next = new Set(base); + if (enabled) { + next.add(normalizedSkill); + } else { + next.delete(normalizedSkill); + } + updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]); + }, + onAgentSkillsClear: (agentId) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + removeConfigFormValue(state, ["agents", "list", index, "skills"]); + }, + onAgentSkillsDisableAll: (agentId) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + updateConfigFormValue(state, ["agents", "list", index, "skills"], []); + }, + onModelChange: (agentId, modelId) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + const basePath = ["agents", "list", index, "model"]; + if (!modelId) { + removeConfigFormValue(state, basePath); + return; + } + const entry = list[index] as { model?: unknown }; + const existing = entry?.model; + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + const fallbacks = (existing as { fallbacks?: unknown }).fallbacks; + const next = { + primary: modelId, + ...(Array.isArray(fallbacks) ? { fallbacks } : {}), + }; + updateConfigFormValue(state, basePath, next); + } else { + updateConfigFormValue(state, basePath, modelId); + } + }, + onModelFallbacksChange: (agentId, fallbacks) => { + if (!configValue) { + return; + } + const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list; + if (!Array.isArray(list)) { + return; + } + const index = list.findIndex( + (entry) => + entry && + typeof entry === "object" && + "id" in entry && + (entry as { id?: string }).id === agentId, + ); + if (index < 0) { + return; + } + const basePath = ["agents", "list", index, "model"]; + const entry = list[index] as { model?: unknown }; + const normalized = fallbacks.map((name) => name.trim()).filter(Boolean); + const existing = entry.model; + const resolvePrimary = () => { + if (typeof existing === "string") { + return existing.trim() || null; + } + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + const primary = (existing as { primary?: unknown }).primary; + if (typeof primary === "string") { + const trimmed = primary.trim(); + return trimmed || null; + } + } + return null; + }; + const primary = resolvePrimary(); + if (normalized.length === 0) { + if (primary) { + updateConfigFormValue(state, basePath, primary); + } else { + removeConfigFormValue(state, basePath); + } + return; + } + const next = primary + ? { primary, fallbacks: normalized } + : { fallbacks: normalized }; + updateConfigFormValue(state, basePath, next); + }, + }) + : nothing + } + ${ state.tab === "skills" ? renderSkills({ diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 58ae113fc8..4b88b04849 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -7,6 +7,9 @@ import { stopDebugPolling, } from "./app-polling"; import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; +import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity"; +import { loadAgentSkills } from "./controllers/agent-skills"; +import { loadAgents } from "./controllers/agents"; import { loadChannels } from "./controllers/channels"; import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadCronJobs, loadCronStatus } from "./controllers/cron"; @@ -185,6 +188,28 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "skills") { await loadSkills(host as unknown as OpenClawApp); } + if (host.tab === "agents") { + await loadAgents(host as unknown as OpenClawApp); + await loadConfig(host as unknown as OpenClawApp); + const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; + if (agentIds.length > 0) { + void loadAgentIdentities(host as unknown as OpenClawApp, agentIds); + } + const agentId = + host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id; + if (agentId) { + void loadAgentIdentity(host as unknown as OpenClawApp, agentId); + if (host.agentsPanel === "skills") { + void loadAgentSkills(host as unknown as OpenClawApp, agentId); + } + if (host.agentsPanel === "channels") { + void loadChannels(host as unknown as OpenClawApp, false); + } + if (host.agentsPanel === "cron") { + void loadCron(host); + } + } + } if (host.tab === "nodes") { await loadNodes(host as unknown as OpenClawApp); await loadDevices(host as unknown as OpenClawApp); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 494644d31a..63368b5802 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -10,6 +10,8 @@ import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; import type { AgentsListResult, + AgentsFilesListResult, + AgentIdentityResult, ChannelsStatusSnapshot, ConfigSnapshot, CronJob, @@ -106,6 +108,22 @@ export type AppViewState = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; + agentsSelectedId: string | null; + agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; + agentFilesLoading: boolean; + agentFilesError: string | null; + agentFilesList: AgentsFilesListResult | null; + agentFileContents: Record; + agentFileDrafts: Record; + agentFileActive: string | null; + agentFileSaving: boolean; + agentIdentityLoading: boolean; + agentIdentityError: string | null; + agentIdentityById: Record; + agentSkillsLoading: boolean; + agentSkillsError: string | null; + agentSkillsReport: SkillStatusReport | null; + agentSkillsAgentId: string | null; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8f7698672b..03f91cc6ce 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -5,11 +5,14 @@ import type { AppViewState } from "./app-view-state"; import type { DevicePairingList } from "./controllers/devices"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; +import type { SkillMessage } from "./controllers/skills"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { Tab } from "./navigation"; import type { ResolvedTheme, ThemeMode } from "./theme"; import type { AgentsListResult, + AgentsFilesListResult, + AgentIdentityResult, ConfigSnapshot, ConfigUiHints, CronJob, @@ -197,6 +200,23 @@ export class OpenClawApp extends LitElement { @state() agentsLoading = false; @state() agentsList: AgentsListResult | null = null; @state() agentsError: string | null = null; + @state() agentsSelectedId: string | null = null; + @state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = + "overview"; + @state() agentFilesLoading = false; + @state() agentFilesError: string | null = null; + @state() agentFilesList: AgentsFilesListResult | null = null; + @state() agentFileContents: Record = {}; + @state() agentFileDrafts: Record = {}; + @state() agentFileActive: string | null = null; + @state() agentFileSaving = false; + @state() agentIdentityLoading = false; + @state() agentIdentityError: string | null = null; + @state() agentIdentityById: Record = {}; + @state() agentSkillsLoading = false; + @state() agentSkillsError: string | null = null; + @state() agentSkillsReport: SkillStatusReport | null = null; + @state() agentSkillsAgentId: string | null = null; @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts index 8134ecc33a..4741027e8f 100644 --- a/ui/src/ui/components/resizable-divider.ts +++ b/ui/src/ui/components/resizable-divider.ts @@ -1,4 +1,4 @@ -import { LitElement, html, css } from "lit"; +import { LitElement, css, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; /** @@ -24,7 +24,7 @@ export class ResizableDivider extends LitElement { flex-shrink: 0; position: relative; } - + :host::before { content: ""; position: absolute; @@ -33,18 +33,18 @@ export class ResizableDivider extends LitElement { right: -4px; bottom: 0; } - + :host(:hover) { background: var(--accent, #007bff); } - + :host(.dragging) { background: var(--accent, #007bff); } `; render() { - return html``; + return nothing; } connectedCallback() { diff --git a/ui/src/ui/controllers/agent-files.ts b/ui/src/ui/controllers/agent-files.ts new file mode 100644 index 0000000000..77d3fe82f4 --- /dev/null +++ b/ui/src/ui/controllers/agent-files.ts @@ -0,0 +1,114 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { + AgentFileEntry, + AgentsFilesGetResult, + AgentsFilesListResult, + AgentsFilesSetResult, +} from "../types"; + +export type AgentFilesState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentFilesLoading: boolean; + agentFilesError: string | null; + agentFilesList: AgentsFilesListResult | null; + agentFileContents: Record; + agentFileDrafts: Record; + agentFileActive: string | null; + agentFileSaving: boolean; +}; + +function mergeFileEntry( + list: AgentsFilesListResult | null, + entry: AgentFileEntry, +): AgentsFilesListResult | null { + if (!list) { + return list; + } + const hasEntry = list.files.some((file) => file.name === entry.name); + const nextFiles = hasEntry + ? list.files.map((file) => (file.name === entry.name ? entry : file)) + : [...list.files, entry]; + return { ...list, files: nextFiles }; +} + +export async function loadAgentFiles(state: AgentFilesState, agentId: string) { + if (!state.client || !state.connected || state.agentFilesLoading) { + return; + } + state.agentFilesLoading = true; + state.agentFilesError = null; + try { + const res = await state.client.request("agents.files.list", { + agentId, + }); + if (res) { + state.agentFilesList = res; + if (state.agentFileActive && !res.files.some((file) => file.name === state.agentFileActive)) { + state.agentFileActive = null; + } + } + } catch (err) { + state.agentFilesError = String(err); + } finally { + state.agentFilesLoading = false; + } +} + +export async function loadAgentFileContent(state: AgentFilesState, agentId: string, name: string) { + if (!state.client || !state.connected || state.agentFilesLoading) { + return; + } + if (Object.hasOwn(state.agentFileContents, name)) { + return; + } + state.agentFilesLoading = true; + state.agentFilesError = null; + try { + const res = await state.client.request("agents.files.get", { + agentId, + name, + }); + if (res?.file) { + const content = res.file.content ?? ""; + state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file); + state.agentFileContents = { ...state.agentFileContents, [name]: content }; + if (!Object.hasOwn(state.agentFileDrafts, name)) { + state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content }; + } + } + } catch (err) { + state.agentFilesError = String(err); + } finally { + state.agentFilesLoading = false; + } +} + +export async function saveAgentFile( + state: AgentFilesState, + agentId: string, + name: string, + content: string, +) { + if (!state.client || !state.connected || state.agentFileSaving) { + return; + } + state.agentFileSaving = true; + state.agentFilesError = null; + try { + const res = await state.client.request("agents.files.set", { + agentId, + name, + content, + }); + if (res?.file) { + state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file); + state.agentFileContents = { ...state.agentFileContents, [name]: content }; + state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content }; + } + } catch (err) { + state.agentFilesError = String(err); + } finally { + state.agentFileSaving = false; + } +} diff --git a/ui/src/ui/controllers/agent-identity.ts b/ui/src/ui/controllers/agent-identity.ts new file mode 100644 index 0000000000..2f5948a394 --- /dev/null +++ b/ui/src/ui/controllers/agent-identity.ts @@ -0,0 +1,59 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { AgentIdentityResult } from "../types"; + +export type AgentIdentityState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentIdentityLoading: boolean; + agentIdentityError: string | null; + agentIdentityById: Record; +}; + +export async function loadAgentIdentity(state: AgentIdentityState, agentId: string) { + if (!state.client || !state.connected || state.agentIdentityLoading) { + return; + } + if (state.agentIdentityById[agentId]) { + return; + } + state.agentIdentityLoading = true; + state.agentIdentityError = null; + try { + const res = await state.client.request("agent.identity.get", { + agentId, + }); + if (res) { + state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res }; + } + } catch (err) { + state.agentIdentityError = String(err); + } finally { + state.agentIdentityLoading = false; + } +} + +export async function loadAgentIdentities(state: AgentIdentityState, agentIds: string[]) { + if (!state.client || !state.connected || state.agentIdentityLoading) { + return; + } + const missing = agentIds.filter((id) => !state.agentIdentityById[id]); + if (missing.length === 0) { + return; + } + state.agentIdentityLoading = true; + state.agentIdentityError = null; + try { + for (const agentId of missing) { + const res = await state.client.request("agent.identity.get", { + agentId, + }); + if (res) { + state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res }; + } + } + } catch (err) { + state.agentIdentityError = String(err); + } finally { + state.agentIdentityLoading = false; + } +} diff --git a/ui/src/ui/controllers/agent-skills.ts b/ui/src/ui/controllers/agent-skills.ts new file mode 100644 index 0000000000..9c5475bba5 --- /dev/null +++ b/ui/src/ui/controllers/agent-skills.ts @@ -0,0 +1,33 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { SkillStatusReport } from "../types"; + +export type AgentSkillsState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentSkillsLoading: boolean; + agentSkillsError: string | null; + agentSkillsReport: SkillStatusReport | null; + agentSkillsAgentId: string | null; +}; + +export async function loadAgentSkills(state: AgentSkillsState, agentId: string) { + if (!state.client || !state.connected) { + return; + } + if (state.agentSkillsLoading) { + return; + } + state.agentSkillsLoading = true; + state.agentSkillsError = null; + try { + const res = await state.client.request("skills.status", { agentId }); + if (res) { + state.agentSkillsReport = res as SkillStatusReport; + state.agentSkillsAgentId = agentId; + } + } catch (err) { + state.agentSkillsError = String(err); + } finally { + state.agentSkillsLoading = false; + } +} diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts index 5d90a2b185..bbaf584ff2 100644 --- a/ui/src/ui/controllers/agents.ts +++ b/ui/src/ui/controllers/agents.ts @@ -7,6 +7,7 @@ export type AgentsState = { agentsLoading: boolean; agentsError: string | null; agentsList: AgentsListResult | null; + agentsSelectedId: string | null; }; export async function loadAgents(state: AgentsState) { @@ -22,6 +23,11 @@ export async function loadAgents(state: AgentsState) { const res = await state.client.request("agents.list", {}); if (res) { state.agentsList = res; + const selected = state.agentsSelectedId; + const known = res.agents.some((entry) => entry.id === selected); + if (!selected || !known) { + state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null; + } } } catch (err) { state.agentsError = String(err); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 44333a7811..38bb90a955 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -6,11 +6,12 @@ export const TAB_GROUPS = [ label: "Control", tabs: ["overview", "channels", "instances", "sessions", "cron"], }, - { label: "Agent", tabs: ["skills", "nodes"] }, + { label: "Agent", tabs: ["agents", "skills", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, ] as const; export type Tab = + | "agents" | "overview" | "channels" | "instances" @@ -24,6 +25,7 @@ export type Tab = | "logs"; const TAB_PATHS: Record = { + agents: "/agents", overview: "/overview", channels: "/channels", instances: "/instances", @@ -120,6 +122,8 @@ export function inferBasePathFromPathname(pathname: string): string { export function iconForTab(tab: Tab): IconName { switch (tab) { + case "agents": + return "folder"; case "chat": return "messageSquare"; case "overview": @@ -149,6 +153,8 @@ export function iconForTab(tab: Tab): IconName { export function titleForTab(tab: Tab) { switch (tab) { + case "agents": + return "Agents"; case "overview": return "Overview"; case "channels": @@ -178,6 +184,8 @@ export function titleForTab(tab: Tab) { export function subtitleForTab(tab: Tab) { switch (tab) { + case "agents": + return "Manage agent workspaces, tools, and identities."; case "overview": return "Gateway status, entry points, and a fast health read."; case "channels": diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index b2ce740c51..7f00dd44c4 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -340,6 +340,41 @@ export type AgentsListResult = { agents: GatewayAgentRow[]; }; +export type AgentIdentityResult = { + agentId: string; + name: string; + avatar: string; + emoji?: string; +}; + +export type AgentFileEntry = { + name: string; + path: string; + missing: boolean; + size?: number; + updatedAtMs?: number; + content?: string; +}; + +export type AgentsFilesListResult = { + agentId: string; + workspace: string; + files: AgentFileEntry[]; +}; + +export type AgentsFilesGetResult = { + agentId: string; + workspace: string; + file: AgentFileEntry; +}; + +export type AgentsFilesSetResult = { + ok: true; + agentId: string; + workspace: string; + file: AgentFileEntry; +}; + export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts new file mode 100644 index 0000000000..5f0aa798d0 --- /dev/null +++ b/ui/src/ui/views/agents.ts @@ -0,0 +1,1950 @@ +import { html, nothing } from "lit"; +import type { + AgentFileEntry, + AgentsFilesListResult, + AgentsListResult, + AgentIdentityResult, + ChannelAccountSnapshot, + ChannelsStatusSnapshot, + CronJob, + CronStatus, + SkillStatusEntry, + SkillStatusReport, +} from "../types"; +import { + expandToolGroups, + normalizeToolName, + resolveToolProfilePolicy, +} from "../../../../src/agents/tool-policy.js"; +import { formatAgo } from "../format"; +import { + formatCronPayload, + formatCronSchedule, + formatCronState, + formatNextRun, +} from "../presenter"; + +export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; + +export type AgentsProps = { + loading: boolean; + error: string | null; + agentsList: AgentsListResult | null; + selectedAgentId: string | null; + activePanel: AgentsPanel; + configForm: Record | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + channelsLoading: boolean; + channelsError: string | null; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsLastSuccess: number | null; + cronLoading: boolean; + cronStatus: CronStatus | null; + cronJobs: CronJob[]; + cronError: string | null; + agentFilesLoading: boolean; + agentFilesError: string | null; + agentFilesList: AgentsFilesListResult | null; + agentFileActive: string | null; + agentFileContents: Record; + agentFileDrafts: Record; + agentFileSaving: boolean; + agentIdentityLoading: boolean; + agentIdentityError: string | null; + agentIdentityById: Record; + agentSkillsLoading: boolean; + agentSkillsReport: SkillStatusReport | null; + agentSkillsError: string | null; + agentSkillsAgentId: string | null; + skillsFilter: string; + onRefresh: () => void; + onSelectAgent: (agentId: string) => void; + onSelectPanel: (panel: AgentsPanel) => void; + onLoadFiles: (agentId: string) => void; + onSelectFile: (name: string) => void; + onFileDraftChange: (name: string, content: string) => void; + onFileReset: (name: string) => void; + onFileSave: (name: string) => void; + onToolsProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; + onToolsOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; + onConfigReload: () => void; + onConfigSave: () => void; + onModelChange: (agentId: string, modelId: string | null) => void; + onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; + onChannelsRefresh: () => void; + onCronRefresh: () => void; + onSkillsFilterChange: (next: string) => void; + onSkillsRefresh: () => void; + onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; + onAgentSkillsClear: (agentId: string) => void; + onAgentSkillsDisableAll: (agentId: string) => void; +}; + +const TOOL_SECTIONS = [ + { + id: "fs", + label: "Files", + tools: [ + { id: "read", label: "read", description: "Read file contents" }, + { id: "write", label: "write", description: "Create or overwrite files" }, + { id: "edit", label: "edit", description: "Make precise edits" }, + { id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" }, + ], + }, + { + id: "runtime", + label: "Runtime", + tools: [ + { id: "exec", label: "exec", description: "Run shell commands" }, + { id: "process", label: "process", description: "Manage background processes" }, + ], + }, + { + id: "web", + label: "Web", + tools: [ + { id: "web_search", label: "web_search", description: "Search the web" }, + { id: "web_fetch", label: "web_fetch", description: "Fetch web content" }, + ], + }, + { + id: "memory", + label: "Memory", + tools: [ + { id: "memory_search", label: "memory_search", description: "Semantic search" }, + { id: "memory_get", label: "memory_get", description: "Read memory files" }, + ], + }, + { + id: "sessions", + label: "Sessions", + tools: [ + { id: "sessions_list", label: "sessions_list", description: "List sessions" }, + { id: "sessions_history", label: "sessions_history", description: "Session history" }, + { id: "sessions_send", label: "sessions_send", description: "Send to session" }, + { id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" }, + { id: "session_status", label: "session_status", description: "Session status" }, + ], + }, + { + id: "ui", + label: "UI", + tools: [ + { id: "browser", label: "browser", description: "Control web browser" }, + { id: "canvas", label: "canvas", description: "Control canvases" }, + ], + }, + { + id: "messaging", + label: "Messaging", + tools: [{ id: "message", label: "message", description: "Send messages" }], + }, + { + id: "automation", + label: "Automation", + tools: [ + { id: "cron", label: "cron", description: "Schedule tasks" }, + { id: "gateway", label: "gateway", description: "Gateway control" }, + ], + }, + { + id: "nodes", + label: "Nodes", + tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }], + }, + { + id: "agents", + label: "Agents", + tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }], + }, + { + id: "media", + label: "Media", + tools: [{ id: "image", label: "image", description: "Image understanding" }], + }, +]; + +const PROFILE_OPTIONS = [ + { id: "minimal", label: "Minimal" }, + { id: "coding", label: "Coding" }, + { id: "messaging", label: "Messaging" }, + { id: "full", label: "Full" }, +] as const; + +type ToolPolicy = { + allow?: string[]; + deny?: string[]; +}; + +type AgentConfigEntry = { + id: string; + name?: string; + workspace?: string; + agentDir?: string; + model?: unknown; + skills?: string[]; + tools?: { + profile?: string; + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; + }; +}; + +type ConfigSnapshot = { + agents?: { + defaults?: { workspace?: string; model?: unknown; models?: Record }; + list?: AgentConfigEntry[]; + }; + tools?: { + profile?: string; + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; + }; +}; + +function normalizeAgentLabel(agent: { id: string; name?: string; identity?: { name?: string } }) { + return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; +} + +function isLikelyEmoji(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed.length > 16) { + return false; + } + let hasNonAscii = false; + for (let i = 0; i < trimmed.length; i += 1) { + if (trimmed.charCodeAt(i) > 127) { + hasNonAscii = true; + break; + } + } + if (!hasNonAscii) { + return false; + } + if (trimmed.includes("://") || trimmed.includes("/") || trimmed.includes(".")) { + return false; + } + return true; +} + +function resolveAgentEmoji( + agent: { identity?: { emoji?: string; avatar?: string } }, + agentIdentity?: AgentIdentityResult | null, +) { + const identityEmoji = agentIdentity?.emoji?.trim(); + if (identityEmoji && isLikelyEmoji(identityEmoji)) { + return identityEmoji; + } + const agentEmoji = agent.identity?.emoji?.trim(); + if (agentEmoji && isLikelyEmoji(agentEmoji)) { + return agentEmoji; + } + const identityAvatar = agentIdentity?.avatar?.trim(); + if (identityAvatar && isLikelyEmoji(identityAvatar)) { + return identityAvatar; + } + const avatar = agent.identity?.avatar?.trim(); + if (avatar && isLikelyEmoji(avatar)) { + return avatar; + } + return ""; +} + +function agentBadgeText(agentId: string, defaultId: string | null) { + return defaultId && agentId === defaultId ? "default" : null; +} + +function formatBytes(bytes?: number) { + if (bytes == null || !Number.isFinite(bytes)) { + return "-"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB", "TB"]; + let size = bytes / 1024; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unitIndex]}`; +} + +function resolveAgentConfig(config: Record | null, agentId: string) { + const cfg = config as ConfigSnapshot | null; + const list = cfg?.agents?.list ?? []; + const entry = list.find((agent) => agent?.id === agentId); + return { + entry, + defaults: cfg?.agents?.defaults, + globalTools: cfg?.tools, + }; +} + +type AgentContext = { + workspace: string; + model: string; + identityName: string; + identityEmoji: string; + skillsLabel: string; + isDefault: boolean; +}; + +function buildAgentContext( + agent: AgentsListResult["agents"][number], + configForm: Record | null, + agentFilesList: AgentsFilesListResult | null, + defaultId: string | null, + agentIdentity?: AgentIdentityResult | null, +): AgentContext { + const config = resolveAgentConfig(configForm, agent.id); + const workspaceFromFiles = + agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; + const workspace = + workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; + const modelLabel = config.entry?.model + ? resolveModelLabel(config.entry?.model) + : resolveModelLabel(config.defaults?.model); + const identityName = + agentIdentity?.name?.trim() || + agent.identity?.name?.trim() || + agent.name?.trim() || + config.entry?.name || + agent.id; + const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-"; + const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; + const skillCount = skillFilter?.length ?? null; + return { + workspace, + model: modelLabel, + identityName, + identityEmoji, + skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", + isDefault: Boolean(defaultId && agent.id === defaultId), + }; +} + +function resolveModelLabel(model?: unknown): string { + if (!model) { + return "-"; + } + if (typeof model === "string") { + return model.trim() || "-"; + } + if (typeof model === "object" && model) { + const record = model as { primary?: string; fallbacks?: string[] }; + const primary = record.primary?.trim(); + if (primary) { + const fallbackCount = Array.isArray(record.fallbacks) ? record.fallbacks.length : 0; + return fallbackCount > 0 ? `${primary} (+${fallbackCount} fallback)` : primary; + } + } + return "-"; +} + +function normalizeModelValue(label: string): string { + const match = label.match(/^(.+) \(\+\d+ fallback\)$/); + return match ? match[1] : label; +} + +function resolveModelPrimary(model?: unknown): string | null { + if (!model) { + return null; + } + if (typeof model === "string") { + const trimmed = model.trim(); + return trimmed || null; + } + if (typeof model === "object" && model) { + const record = model as Record; + const candidate = + typeof record.primary === "string" + ? record.primary + : typeof record.model === "string" + ? record.model + : typeof record.id === "string" + ? record.id + : typeof record.value === "string" + ? record.value + : null; + const primary = candidate?.trim(); + return primary || null; + } + return null; +} + +function resolveModelFallbacks(model?: unknown): string[] | null { + if (!model || typeof model === "string") { + return null; + } + if (typeof model === "object" && model) { + const record = model as Record; + const fallbacks = Array.isArray(record.fallbacks) + ? record.fallbacks + : Array.isArray(record.fallback) + ? record.fallback + : null; + return fallbacks + ? fallbacks.filter((entry): entry is string => typeof entry === "string") + : null; + } + return null; +} + +function parseFallbackList(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +type ConfiguredModelOption = { + value: string; + label: string; +}; + +function resolveConfiguredModels( + configForm: Record | null, +): ConfiguredModelOption[] { + const cfg = configForm as ConfigSnapshot | null; + const models = cfg?.agents?.defaults?.models; + if (!models || typeof models !== "object") { + return []; + } + const options: ConfiguredModelOption[] = []; + for (const [modelId, modelRaw] of Object.entries(models)) { + const trimmed = modelId.trim(); + if (!trimmed) { + continue; + } + const alias = + modelRaw && typeof modelRaw === "object" && "alias" in modelRaw + ? typeof (modelRaw as { alias?: unknown }).alias === "string" + ? (modelRaw as { alias?: string }).alias?.trim() + : undefined + : undefined; + const label = alias && alias !== trimmed ? `${alias} (${trimmed})` : trimmed; + options.push({ value: trimmed, label }); + } + return options; +} + +function buildModelOptions(configForm: Record | null, current?: string | null) { + const options = resolveConfiguredModels(configForm); + const hasCurrent = current ? options.some((option) => option.value === current) : false; + if (current && !hasCurrent) { + options.unshift({ value: current, label: `Current (${current})` }); + } + if (options.length === 0) { + return html` + + `; + } + return options.map((option) => html``); +} + +type CompiledPattern = + | { kind: "all" } + | { kind: "exact"; value: string } + | { kind: "regex"; value: RegExp }; + +function compilePattern(pattern: string): CompiledPattern { + const normalized = normalizeToolName(pattern); + if (!normalized) { + return { kind: "exact", value: "" }; + } + if (normalized === "*") { + return { kind: "all" }; + } + if (!normalized.includes("*")) { + return { kind: "exact", value: normalized }; + } + const escaped = normalized.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&"); + return { kind: "regex", value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`) }; +} + +function compilePatterns(patterns?: string[]): CompiledPattern[] { + if (!Array.isArray(patterns)) { + return []; + } + return expandToolGroups(patterns) + .map(compilePattern) + .filter((pattern) => { + return pattern.kind !== "exact" || pattern.value.length > 0; + }); +} + +function matchesAny(name: string, patterns: CompiledPattern[]) { + for (const pattern of patterns) { + if (pattern.kind === "all") { + return true; + } + if (pattern.kind === "exact" && name === pattern.value) { + return true; + } + if (pattern.kind === "regex" && pattern.value.test(name)) { + return true; + } + } + return false; +} + +function isAllowedByPolicy(name: string, policy?: ToolPolicy) { + if (!policy) { + return true; + } + const normalized = normalizeToolName(name); + const deny = compilePatterns(policy.deny); + if (matchesAny(normalized, deny)) { + return false; + } + const allow = compilePatterns(policy.allow); + if (allow.length === 0) { + return true; + } + if (matchesAny(normalized, allow)) { + return true; + } + if (normalized === "apply_patch" && matchesAny("exec", allow)) { + return true; + } + return false; +} + +function matchesList(name: string, list?: string[]) { + if (!Array.isArray(list) || list.length === 0) { + return false; + } + const normalized = normalizeToolName(name); + const patterns = compilePatterns(list); + if (matchesAny(normalized, patterns)) { + return true; + } + if (normalized === "apply_patch" && matchesAny("exec", patterns)) { + return true; + } + return false; +} + +export function renderAgents(props: AgentsProps) { + const agents = props.agentsList?.agents ?? []; + const defaultId = props.agentsList?.defaultId ?? null; + const selectedId = props.selectedAgentId ?? defaultId ?? agents[0]?.id ?? null; + const selectedAgent = selectedId + ? (agents.find((agent) => agent.id === selectedId) ?? null) + : null; + + return html` +
+
+
+
+
Agents
+
${agents.length} configured.
+
+ +
+ ${ + props.error + ? html`
${props.error}
` + : nothing + } +
+ ${ + agents.length === 0 + ? html` +
No agents found.
+ ` + : agents.map((agent) => { + const badge = agentBadgeText(agent.id, defaultId); + const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); + return html` + + `; + }) + } +
+
+
+ ${ + !selectedAgent + ? html` +
+
Select an agent
+
Pick an agent to inspect its workspace and tools.
+
+ ` + : html` + ${renderAgentHeader( + selectedAgent, + defaultId, + props.agentIdentityById[selectedAgent.id] ?? null, + )} + ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} + ${ + props.activePanel === "overview" + ? renderAgentOverview({ + agent: selectedAgent, + defaultId, + configForm: props.configForm, + agentFilesList: props.agentFilesList, + agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, + agentIdentityError: props.agentIdentityError, + agentIdentityLoading: props.agentIdentityLoading, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + onModelChange: props.onModelChange, + onModelFallbacksChange: props.onModelFallbacksChange, + }) + : nothing + } + ${ + props.activePanel === "files" + ? renderAgentFiles({ + agentId: selectedAgent.id, + agentFilesList: props.agentFilesList, + agentFilesLoading: props.agentFilesLoading, + agentFilesError: props.agentFilesError, + agentFileActive: props.agentFileActive, + agentFileContents: props.agentFileContents, + agentFileDrafts: props.agentFileDrafts, + agentFileSaving: props.agentFileSaving, + onLoadFiles: props.onLoadFiles, + onSelectFile: props.onSelectFile, + onFileDraftChange: props.onFileDraftChange, + onFileReset: props.onFileReset, + onFileSave: props.onFileSave, + }) + : nothing + } + ${ + props.activePanel === "tools" + ? renderAgentTools({ + agentId: selectedAgent.id, + configForm: props.configForm, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + onProfileChange: props.onToolsProfileChange, + onOverridesChange: props.onToolsOverridesChange, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + }) + : nothing + } + ${ + props.activePanel === "skills" + ? renderAgentSkills({ + agentId: selectedAgent.id, + report: props.agentSkillsReport, + loading: props.agentSkillsLoading, + error: props.agentSkillsError, + activeAgentId: props.agentSkillsAgentId, + configForm: props.configForm, + configLoading: props.configLoading, + configSaving: props.configSaving, + configDirty: props.configDirty, + filter: props.skillsFilter, + onFilterChange: props.onSkillsFilterChange, + onRefresh: props.onSkillsRefresh, + onToggle: props.onAgentSkillToggle, + onClear: props.onAgentSkillsClear, + onDisableAll: props.onAgentSkillsDisableAll, + onConfigReload: props.onConfigReload, + onConfigSave: props.onConfigSave, + }) + : nothing + } + ${ + props.activePanel === "channels" + ? renderAgentChannels({ + agent: selectedAgent, + defaultId, + configForm: props.configForm, + agentFilesList: props.agentFilesList, + agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, + snapshot: props.channelsSnapshot, + loading: props.channelsLoading, + error: props.channelsError, + lastSuccess: props.channelsLastSuccess, + onRefresh: props.onChannelsRefresh, + }) + : nothing + } + ${ + props.activePanel === "cron" + ? renderAgentCron({ + agent: selectedAgent, + defaultId, + configForm: props.configForm, + agentFilesList: props.agentFilesList, + agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, + jobs: props.cronJobs, + status: props.cronStatus, + loading: props.cronLoading, + error: props.cronError, + onRefresh: props.onCronRefresh, + }) + : nothing + } + ` + } +
+
+ `; +} + +function renderAgentHeader( + agent: AgentsListResult["agents"][number], + defaultId: string | null, + agentIdentity: AgentIdentityResult | null, +) { + const badge = agentBadgeText(agent.id, defaultId); + const displayName = normalizeAgentLabel(agent); + const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing."; + const emoji = resolveAgentEmoji(agent, agentIdentity); + return html` +
+
+
+ ${emoji || displayName.slice(0, 1)} +
+
+
${displayName}
+
${subtitle}
+
+
+
+
${agent.id}
+ ${badge ? html`${badge}` : nothing} +
+
+ `; +} + +function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { + const tabs: Array<{ id: AgentsPanel; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "files", label: "Files" }, + { id: "tools", label: "Tools" }, + { id: "skills", label: "Skills" }, + { id: "channels", label: "Channels" }, + { id: "cron", label: "Cron Jobs" }, + ]; + return html` +
+ ${tabs.map( + (tab) => html` + + `, + )} +
+ `; +} + +function renderAgentOverview(params: { + agent: AgentsListResult["agents"][number]; + defaultId: string | null; + configForm: Record | null; + agentFilesList: AgentsFilesListResult | null; + agentIdentity: AgentIdentityResult | null; + agentIdentityLoading: boolean; + agentIdentityError: string | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + onConfigReload: () => void; + onConfigSave: () => void; + onModelChange: (agentId: string, modelId: string | null) => void; + onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; +}) { + const { + agent, + configForm, + agentFilesList, + agentIdentity, + agentIdentityLoading, + agentIdentityError, + configLoading, + configSaving, + configDirty, + onConfigReload, + onConfigSave, + onModelChange, + onModelFallbacksChange, + } = params; + const config = resolveAgentConfig(configForm, agent.id); + const workspaceFromFiles = + agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null; + const workspace = + workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default"; + const model = config.entry?.model + ? resolveModelLabel(config.entry?.model) + : resolveModelLabel(config.defaults?.model); + const defaultModel = resolveModelLabel(config.defaults?.model); + const modelPrimary = + resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null); + const defaultPrimary = + resolveModelPrimary(config.defaults?.model) || + (defaultModel !== "-" ? normalizeModelValue(defaultModel) : null); + const effectivePrimary = modelPrimary ?? defaultPrimary ?? null; + const modelFallbacks = resolveModelFallbacks(config.entry?.model); + const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : ""; + const identityName = + agentIdentity?.name?.trim() || + agent.identity?.name?.trim() || + agent.name?.trim() || + config.entry?.name || + "-"; + const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity); + const identityEmoji = resolvedEmoji || "-"; + const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; + const skillCount = skillFilter?.length ?? null; + const identityStatus = agentIdentityLoading + ? "Loading…" + : agentIdentityError + ? "Unavailable" + : ""; + const isDefault = Boolean(params.defaultId && agent.id === params.defaultId); + + return html` +
+
Overview
+
Workspace paths and identity metadata.
+
+
+
Workspace
+
${workspace}
+
+
+
Primary Model
+
${model}
+
+
+
Identity Name
+
${identityName}
+ ${identityStatus ? html`
${identityStatus}
` : nothing} +
+
+
Default
+
${isDefault ? "yes" : "no"}
+
+
+
Identity Emoji
+
${identityEmoji}
+
+
+
Skills Filter
+
${skillFilter ? `${skillCount} selected` : "all skills"}
+
+
+ +
+
Model Selection
+
+ + +
+
+ + +
+
+
+ `; +} + +function renderAgentContextCard(context: AgentContext, subtitle: string) { + return html` +
+
Agent Context
+
${subtitle}
+
+
+
Workspace
+
${context.workspace}
+
+
+
Primary Model
+
${context.model}
+
+
+
Identity Name
+
${context.identityName}
+
+
+
Identity Emoji
+
${context.identityEmoji}
+
+
+
Skills Filter
+
${context.skillsLabel}
+
+
+
Default
+
${context.isDefault ? "yes" : "no"}
+
+
+
+ `; +} + +type ChannelSummaryEntry = { + id: string; + label: string; + accounts: ChannelAccountSnapshot[]; +}; + +function resolveChannelLabel(snapshot: ChannelsStatusSnapshot, id: string) { + const meta = snapshot.channelMeta?.find((entry) => entry.id === id); + if (meta?.label) { + return meta.label; + } + return snapshot.channelLabels?.[id] ?? id; +} + +function resolveChannelEntries(snapshot: ChannelsStatusSnapshot | null): ChannelSummaryEntry[] { + if (!snapshot) { + return []; + } + const ids = new Set(); + for (const id of snapshot.channelOrder ?? []) { + ids.add(id); + } + for (const entry of snapshot.channelMeta ?? []) { + ids.add(entry.id); + } + for (const id of Object.keys(snapshot.channelAccounts ?? {})) { + ids.add(id); + } + const ordered: string[] = []; + const seed = snapshot.channelOrder?.length ? snapshot.channelOrder : Array.from(ids); + for (const id of seed) { + if (!ids.has(id)) { + continue; + } + ordered.push(id); + ids.delete(id); + } + for (const id of ids) { + ordered.push(id); + } + return ordered.map((id) => ({ + id, + label: resolveChannelLabel(snapshot, id), + accounts: snapshot.channelAccounts?.[id] ?? [], + })); +} + +const CHANNEL_EXTRA_FIELDS = ["groupPolicy", "streamMode", "dmPolicy"] as const; + +function resolveChannelConfigValue( + configForm: Record | null, + channelId: string, +): Record | null { + if (!configForm) { + return null; + } + const channels = (configForm.channels ?? {}) as Record; + const fromChannels = channels[channelId]; + if (fromChannels && typeof fromChannels === "object") { + return fromChannels as Record; + } + const fallback = configForm[channelId]; + if (fallback && typeof fallback === "object") { + return fallback as Record; + } + return null; +} + +function formatChannelExtraValue(raw: unknown): string { + if (raw == null) { + return "n/a"; + } + if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") { + return String(raw); + } + try { + return JSON.stringify(raw); + } catch { + return "n/a"; + } +} + +function resolveChannelExtras( + configForm: Record | null, + channelId: string, +): Array<{ label: string; value: string }> { + const value = resolveChannelConfigValue(configForm, channelId); + if (!value) { + return []; + } + return CHANNEL_EXTRA_FIELDS.flatMap((field) => { + if (!(field in value)) { + return []; + } + return [{ label: field, value: formatChannelExtraValue(value[field]) }]; + }); +} + +function summarizeChannelAccounts(accounts: ChannelAccountSnapshot[]) { + let connected = 0; + let configured = 0; + let enabled = 0; + for (const account of accounts) { + const probeOk = + account.probe && typeof account.probe === "object" && "ok" in account.probe + ? Boolean((account.probe as { ok?: unknown }).ok) + : false; + const isConnected = account.connected === true || account.running === true || probeOk; + if (isConnected) { + connected += 1; + } + if (account.configured) { + configured += 1; + } + if (account.enabled) { + enabled += 1; + } + } + return { + total: accounts.length, + connected, + configured, + enabled, + }; +} + +function renderAgentChannels(params: { + agent: AgentsListResult["agents"][number]; + defaultId: string | null; + configForm: Record | null; + agentFilesList: AgentsFilesListResult | null; + agentIdentity: AgentIdentityResult | null; + snapshot: ChannelsStatusSnapshot | null; + loading: boolean; + error: string | null; + lastSuccess: number | null; + onRefresh: () => void; +}) { + const context = buildAgentContext( + params.agent, + params.configForm, + params.agentFilesList, + params.defaultId, + params.agentIdentity, + ); + const entries = resolveChannelEntries(params.snapshot); + const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never"; + return html` +
+ ${renderAgentContextCard(context, "Workspace, identity, and model configuration.")} +
+
+
+
Channels
+
Gateway-wide channel status snapshot.
+
+ +
+
+ Last refresh: ${lastSuccessLabel} +
+ ${ + params.error + ? html`
${params.error}
` + : nothing + } + ${ + !params.snapshot + ? html` +
Load channels to see live status.
+ ` + : nothing + } + ${ + entries.length === 0 + ? html` +
No channels found.
+ ` + : html` +
+ ${entries.map((entry) => { + const summary = summarizeChannelAccounts(entry.accounts); + const status = summary.total + ? `${summary.connected}/${summary.total} connected` + : "no accounts"; + const config = summary.configured + ? `${summary.configured} configured` + : "not configured"; + const enabled = summary.total ? `${summary.enabled} enabled` : "disabled"; + const extras = resolveChannelExtras(params.configForm, entry.id); + return html` +
+
+
${entry.label}
+
${entry.id}
+
+
+
${status}
+
${config}
+
${enabled}
+ ${ + extras.length > 0 + ? extras.map((extra) => html`
${extra.label}: ${extra.value}
`) + : nothing + } +
+
+ `; + })} +
+ ` + } +
+
+ `; +} + +function renderAgentCron(params: { + agent: AgentsListResult["agents"][number]; + defaultId: string | null; + configForm: Record | null; + agentFilesList: AgentsFilesListResult | null; + agentIdentity: AgentIdentityResult | null; + jobs: CronJob[]; + status: CronStatus | null; + loading: boolean; + error: string | null; + onRefresh: () => void; +}) { + const context = buildAgentContext( + params.agent, + params.configForm, + params.agentFilesList, + params.defaultId, + params.agentIdentity, + ); + const jobs = params.jobs.filter((job) => job.agentId === params.agent.id); + return html` +
+ ${renderAgentContextCard(context, "Workspace and scheduling targets.")} +
+
+
+
Scheduler
+
Gateway cron status.
+
+ +
+
+
+
Enabled
+
+ ${params.status ? (params.status.enabled ? "Yes" : "No") : "n/a"} +
+
+
+
Jobs
+
${params.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(params.status?.nextWakeAtMs ?? null)}
+
+
+ ${ + params.error + ? html`
${params.error}
` + : nothing + } +
+
+
+
Agent Cron Jobs
+
Scheduled jobs targeting this agent.
+ ${ + jobs.length === 0 + ? html` +
No jobs assigned.
+ ` + : html` +
+ ${jobs.map( + (job) => html` +
+
+
${job.name}
+ ${job.description ? html`
${job.description}
` : nothing} +
+ ${formatCronSchedule(job)} + + ${job.enabled ? "enabled" : "disabled"} + + ${job.sessionTarget} +
+
+
+
${formatCronState(job)}
+
${formatCronPayload(job)}
+
+
+ `, + )} +
+ ` + } +
+ `; +} + +function renderAgentFiles(params: { + agentId: string; + agentFilesList: AgentsFilesListResult | null; + agentFilesLoading: boolean; + agentFilesError: string | null; + agentFileActive: string | null; + agentFileContents: Record; + agentFileDrafts: Record; + agentFileSaving: boolean; + onLoadFiles: (agentId: string) => void; + onSelectFile: (name: string) => void; + onFileDraftChange: (name: string, content: string) => void; + onFileReset: (name: string) => void; + onFileSave: (name: string) => void; +}) { + const list = params.agentFilesList?.agentId === params.agentId ? params.agentFilesList : null; + const files = list?.files ?? []; + const active = params.agentFileActive ?? null; + const activeEntry = active ? (files.find((file) => file.name === active) ?? null) : null; + const baseContent = active ? (params.agentFileContents[active] ?? "") : ""; + const draft = active ? (params.agentFileDrafts[active] ?? baseContent) : ""; + const isDirty = active ? draft !== baseContent : false; + + return html` +
+
+
+
Core Files
+
Bootstrap persona, identity, and tool guidance.
+
+ +
+ ${list ? html`
Workspace: ${list.workspace}
` : nothing} + ${ + params.agentFilesError + ? html`
${ + params.agentFilesError + }
` + : nothing + } + ${ + !list + ? html` +
+ Load the agent workspace files to edit core instructions. +
+ ` + : html` +
+
+ ${ + files.length === 0 + ? html` +
No files found.
+ ` + : files.map((file) => + renderAgentFileRow(file, active, () => params.onSelectFile(file.name)), + ) + } +
+
+ ${ + !activeEntry + ? html` +
Select a file to edit.
+ ` + : html` +
+
+
${activeEntry.name}
+
${activeEntry.path}
+
+
+ + +
+
+ ${ + activeEntry.missing + ? html` +
+ This file is missing. Saving will create it in the agent workspace. +
+ ` + : nothing + } + + ` + } +
+
+ ` + } +
+ `; +} + +function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) { + const status = file.missing + ? "Missing" + : `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`; + return html` + + `; +} + +function renderAgentTools(params: { + agentId: string; + configForm: Record | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void; + onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void; + onConfigReload: () => void; + onConfigSave: () => void; +}) { + const config = resolveAgentConfig(params.configForm, params.agentId); + const agentTools = config.entry?.tools ?? {}; + const globalTools = config.globalTools ?? {}; + const profile = agentTools.profile ?? globalTools.profile ?? "full"; + const profileSource = agentTools.profile + ? "agent override" + : globalTools.profile + ? "global default" + : "default"; + const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0; + const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0; + const editable = + Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow; + const alsoAllow = hasAgentAllow + ? [] + : Array.isArray(agentTools.alsoAllow) + ? agentTools.alsoAllow + : []; + const deny = hasAgentAllow ? [] : Array.isArray(agentTools.deny) ? agentTools.deny : []; + const basePolicy = hasAgentAllow + ? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] } + : (resolveToolProfilePolicy(profile) ?? undefined); + const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id)); + + const resolveAllowed = (toolId: string) => { + const baseAllowed = isAllowedByPolicy(toolId, basePolicy); + const extraAllowed = matchesList(toolId, alsoAllow); + const denied = matchesList(toolId, deny); + const allowed = (baseAllowed || extraAllowed) && !denied; + return { + allowed, + baseAllowed, + denied, + }; + }; + const enabledCount = toolIds.filter((toolId) => resolveAllowed(toolId).allowed).length; + + const updateTool = (toolId: string, nextEnabled: boolean) => { + const nextAllow = new Set( + alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0), + ); + const nextDeny = new Set( + deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0), + ); + const baseAllowed = resolveAllowed(toolId).baseAllowed; + const normalized = normalizeToolName(toolId); + if (nextEnabled) { + nextDeny.delete(normalized); + if (!baseAllowed) { + nextAllow.add(normalized); + } + } else { + nextAllow.delete(normalized); + nextDeny.add(normalized); + } + params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]); + }; + + const updateAll = (nextEnabled: boolean) => { + const nextAllow = new Set( + alsoAllow.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0), + ); + const nextDeny = new Set( + deny.map((entry) => normalizeToolName(entry)).filter((entry) => entry.length > 0), + ); + for (const toolId of toolIds) { + const baseAllowed = resolveAllowed(toolId).baseAllowed; + const normalized = normalizeToolName(toolId); + if (nextEnabled) { + nextDeny.delete(normalized); + if (!baseAllowed) { + nextAllow.add(normalized); + } + } else { + nextAllow.delete(normalized); + nextDeny.add(normalized); + } + } + params.onOverridesChange(params.agentId, [...nextAllow], [...nextDeny]); + }; + + return html` +
+
+
+
Tool Access
+
+ Profile + per-tool overrides for this agent. + ${enabledCount}/${toolIds.length} enabled. +
+
+
+ + + + +
+
+ + ${ + !params.configForm + ? html` +
+ Load the gateway config to adjust tool profiles. +
+ ` + : nothing + } + ${ + hasAgentAllow + ? html` +
+ This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab. +
+ ` + : nothing + } + ${ + hasGlobalAllow + ? html` +
+ Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked. +
+ ` + : nothing + } + +
+
+
Profile
+
${profile}
+
+
+
Source
+
${profileSource}
+
+ ${ + params.configDirty + ? html` +
+
Status
+
unsaved
+
+ ` + : nothing + } +
+ +
+
Quick Presets
+
+ ${PROFILE_OPTIONS.map( + (option) => html` + + `, + )} + +
+
+ +
+ ${TOOL_SECTIONS.map( + (section) => + html` +
+
${section.label}
+
+ ${section.tools.map((tool) => { + const { allowed } = resolveAllowed(tool.id); + return html` +
+
+
${tool.label}
+
${tool.description}
+
+ +
+ `; + })} +
+
+ `, + )} +
+
+ `; +} + +type SkillGroup = { + id: string; + label: string; + skills: SkillStatusEntry[]; +}; + +const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [ + { id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] }, + { id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] }, + { id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] }, + { id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] }, +]; + +function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { + const groups = new Map(); + for (const def of SKILL_SOURCE_GROUPS) { + groups.set(def.id, { id: def.id, label: def.label, skills: [] }); + } + const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; + for (const skill of skills) { + const match = SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); + if (match) { + groups.get(match.id)?.skills.push(skill); + } else { + other.skills.push(skill); + } + } + const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter( + (group): group is SkillGroup => Boolean(group && group.skills.length > 0), + ); + if (other.skills.length > 0) { + ordered.push(other); + } + return ordered; +} + +function renderAgentSkills(params: { + agentId: string; + report: SkillStatusReport | null; + loading: boolean; + error: string | null; + activeAgentId: string | null; + configForm: Record | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + filter: string; + onFilterChange: (next: string) => void; + onRefresh: () => void; + onToggle: (agentId: string, skillName: string, enabled: boolean) => void; + onClear: (agentId: string) => void; + onDisableAll: (agentId: string) => void; + onConfigReload: () => void; + onConfigSave: () => void; +}) { + const editable = Boolean(params.configForm) && !params.configLoading && !params.configSaving; + const config = resolveAgentConfig(params.configForm, params.agentId); + const allowlist = Array.isArray(config.entry?.skills) ? config.entry?.skills : undefined; + const allowSet = new Set((allowlist ?? []).map((name) => name.trim()).filter(Boolean)); + const usingAllowlist = allowlist !== undefined; + const reportReady = Boolean(params.report && params.activeAgentId === params.agentId); + const rawSkills = reportReady ? (params.report?.skills ?? []) : []; + const filter = params.filter.trim().toLowerCase(); + const filtered = filter + ? rawSkills.filter((skill) => + [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), + ) + : rawSkills; + const groups = groupSkills(filtered); + const enabledCount = usingAllowlist + ? rawSkills.filter((skill) => allowSet.has(skill.name)).length + : rawSkills.length; + const totalCount = rawSkills.length; + + return html` +
+
+
+
Skills
+
+ Per-agent skill allowlist and workspace skills. + ${totalCount > 0 ? html`${enabledCount}/${totalCount}` : nothing} +
+
+
+ + + + + +
+
+ + ${ + !params.configForm + ? html` +
+ Load the gateway config to set per-agent skills. +
+ ` + : nothing + } + ${ + usingAllowlist + ? html` +
This agent uses a custom skill allowlist.
+ ` + : html` +
+ All skills are enabled. Disabling any skill will create a per-agent allowlist. +
+ ` + } + ${ + !reportReady && !params.loading + ? html` +
+ Load skills for this agent to view workspace-specific entries. +
+ ` + : nothing + } + ${ + params.error + ? html`
${params.error}
` + : nothing + } + +
+ +
${filtered.length} shown
+
+ + ${ + filtered.length === 0 + ? html` +
No skills found.
+ ` + : html` +
+ ${groups.map((group) => + renderAgentSkillGroup(group, { + agentId: params.agentId, + allowSet, + usingAllowlist, + editable, + onToggle: params.onToggle, + }), + )} +
+ ` + } +
+ `; +} + +function renderAgentSkillGroup( + group: SkillGroup, + params: { + agentId: string; + allowSet: Set; + usingAllowlist: boolean; + editable: boolean; + onToggle: (agentId: string, skillName: string, enabled: boolean) => void; + }, +) { + const collapsedByDefault = group.id === "workspace" || group.id === "built-in"; + return html` +
+ + ${group.label} + ${group.skills.length} + +
+ ${group.skills.map((skill) => + renderAgentSkillRow(skill, { + agentId: params.agentId, + allowSet: params.allowSet, + usingAllowlist: params.usingAllowlist, + editable: params.editable, + onToggle: params.onToggle, + }), + )} +
+
+ `; +} + +function renderAgentSkillRow( + skill: SkillStatusEntry, + params: { + agentId: string; + allowSet: Set; + usingAllowlist: boolean; + editable: boolean; + onToggle: (agentId: string, skillName: string, enabled: boolean) => void; + }, +) { + const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true; + const missing = [ + ...skill.missing.bins.map((b) => `bin:${b}`), + ...skill.missing.env.map((e) => `env:${e}`), + ...skill.missing.config.map((c) => `config:${c}`), + ...skill.missing.os.map((o) => `os:${o}`), + ]; + const reasons: string[] = []; + if (skill.disabled) { + reasons.push("disabled"); + } + if (skill.blockedByAllowlist) { + reasons.push("blocked by allowlist"); + } + return html` +
+
+
+ ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name} +
+
${skill.description}
+
+ ${skill.source} + + ${skill.eligible ? "eligible" : "blocked"} + + ${ + skill.disabled + ? html` + disabled + ` + : nothing + } +
+ ${ + missing.length > 0 + ? html`
Missing: ${missing.join(", ")}
` + : nothing + } + ${ + reasons.length > 0 + ? html`
Reason: ${reasons.join(", ")}
` + : nothing + } +
+
+ +
+
+ `; +} diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts index 6ee0e13623..a723d35611 100644 --- a/ui/src/ui/views/channels.config.ts +++ b/ui/src/ui/views/channels.config.ts @@ -63,6 +63,46 @@ function resolveChannelValue( return resolved ?? {}; } +const EXTRA_CHANNEL_FIELDS = ["groupPolicy", "streamMode", "dmPolicy"] as const; + +function formatExtraValue(raw: unknown): string { + if (raw == null) { + return "n/a"; + } + if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") { + return String(raw); + } + try { + return JSON.stringify(raw); + } catch { + return "n/a"; + } +} + +function renderExtraChannelFields(value: Record) { + const entries = EXTRA_CHANNEL_FIELDS.flatMap((field) => { + if (!(field in value)) { + return []; + } + return [[field, value[field]]] as Array<[string, unknown]>; + }); + if (entries.length === 0) { + return null; + } + return html` +
+ ${entries.map( + ([field, raw]) => html` +
+ ${field} + ${formatExtraValue(raw)} +
+ `, + )} +
+ `; +} + export function renderChannelConfigForm(props: ChannelConfigFormProps) { const analysis = analyzeConfigSchema(props.schema); const normalized = analysis.schema; @@ -92,6 +132,7 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) { onPatch: props.onPatch, })}
+ ${renderExtraChannelFields(value)} `; } diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index e9d0e04821..a0f0d7d066 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -3,6 +3,42 @@ import type { SkillMessageMap } from "../controllers/skills"; import type { SkillStatusEntry, SkillStatusReport } from "../types"; import { clampText } from "../format"; +type SkillGroup = { + id: string; + label: string; + skills: SkillStatusEntry[]; +}; + +const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [ + { id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] }, + { id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] }, + { id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] }, + { id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] }, +]; + +function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { + const groups = new Map(); + for (const def of SKILL_SOURCE_GROUPS) { + groups.set(def.id, { id: def.id, label: def.label, skills: [] }); + } + const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; + for (const skill of skills) { + const match = SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); + if (match) { + groups.get(match.id)?.skills.push(skill); + } else { + other.skills.push(skill); + } + } + const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter( + (group): group is SkillGroup => Boolean(group && group.skills.length > 0), + ); + if (other.skills.length > 0) { + ordered.push(other); + } + return ordered; +} + export type SkillsProps = { loading: boolean; report: SkillStatusReport | null; @@ -27,6 +63,7 @@ export function renderSkills(props: SkillsProps) { [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), ) : skills; + const groups = groupSkills(filtered); return html`
@@ -64,8 +101,21 @@ export function renderSkills(props: SkillsProps) {
No skills found.
` : html` -
- ${filtered.map((skill) => renderSkill(skill, props))} +
+ ${groups.map((group) => { + const collapsedByDefault = group.id === "workspace" || group.id === "built-in"; + return html` +
+ + ${group.label} + ${group.skills.length} + +
+ ${group.skills.map((skill) => renderSkill(skill, props))} +
+
+ `; + })}
` }