feat(ui): add Agents dashboard

This commit is contained in:
Gustavo Madeira Santana
2026-02-02 21:31:17 -05:00
parent c8af8e9555
commit 2a68bcbeb3
32 changed files with 3652 additions and 21 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;

View File

@@ -124,6 +124,7 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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":

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
export const validateAgentsFilesListParams = ajv.compile<AgentsFilesListParams>(
AgentsFilesListParamsSchema,
);
export const validateAgentsFilesGetParams = ajv.compile<AgentsFilesGetParams>(
AgentsFilesGetParamsSchema,
);
export const validateAgentsFilesSetParams = ajv.compile<AgentsFilesSetParams>(
AgentsFilesSetParamsSchema,
);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
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,

View File

@@ -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 },
);

View File

@@ -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 });

View File

@@ -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<string, TSchema> = {
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,

View File

@@ -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<typeof ChannelsLogoutParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>;
export type AgentsFileEntry = Static<typeof AgentsFileEntrySchema>;
export type AgentsFilesListParams = Static<typeof AgentsFilesListParamsSchema>;
export type AgentsFilesListResult = Static<typeof AgentsFilesListResultSchema>;
export type AgentsFilesGetParams = Static<typeof AgentsFilesGetParamsSchema>;
export type AgentsFilesGetResult = Static<typeof AgentsFilesGetResultSchema>;
export type AgentsFilesSetParams = Static<typeof AgentsFilesSetParamsSchema>;
export type AgentsFilesSetResult = Static<typeof AgentsFilesSetResultSchema>;
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;

View File

@@ -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",

View File

@@ -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<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
type FileMeta = {
size: number;
updatedAtMs: number;
};
async function statFile(filePath: string): Promise<FileMeta | null> {
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<typeof loadConfig>) {
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,
);
},
};

View File

@@ -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() },

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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<string, unknown> | null);
const resolvedAgentId =
state.agentsSelectedId ??
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
@@ -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({

View File

@@ -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);

View File

@@ -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<string, string>;
agentFileDrafts: Record<string, string>;
agentFileActive: string | null;
agentFileSaving: boolean;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkillsLoading: boolean;
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;

View File

@@ -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<string, string> = {};
@state() agentFileDrafts: Record<string, string> = {};
@state() agentFileActive: string | null = null;
@state() agentFileSaving = false;
@state() agentIdentityLoading = false;
@state() agentIdentityError: string | null = null;
@state() agentIdentityById: Record<string, AgentIdentityResult> = {};
@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;

View File

@@ -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() {

View File

@@ -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<string, string>;
agentFileDrafts: Record<string, string>;
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<AgentsFilesListResult | null>("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<AgentsFilesGetResult | null>("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<AgentsFilesSetResult | null>("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;
}
}

View File

@@ -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<string, AgentIdentityResult>;
};
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<AgentIdentityResult | null>("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<AgentIdentityResult | null>("agent.identity.get", {
agentId,
});
if (res) {
state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res };
}
}
} catch (err) {
state.agentIdentityError = String(err);
} finally {
state.agentIdentityLoading = false;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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<Tab, string> = {
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":

View File

@@ -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";

1950
ui/src/ui/views/agents.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown>) {
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`
<div class="status-list" style="margin-top: 12px;">
${entries.map(
([field, raw]) => html`
<div>
<span class="label">${field}</span>
<span>${formatExtraValue(raw)}</span>
</div>
`,
)}
</div>
`;
}
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,
})}
</div>
${renderExtraChannelFields(value)}
`;
}

View File

@@ -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<string, SkillGroup>();
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`
<section class="card">
@@ -64,8 +101,21 @@ export function renderSkills(props: SkillsProps) {
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) => {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return html`
<details class="agent-skills-group" ?open=${!collapsedByDefault}>
<summary class="agent-skills-header">
<span>${group.label}</span>
<span class="muted">${group.skills.length}</span>
</summary>
<div class="list skills-grid">
${group.skills.map((skill) => renderSkill(skill, props))}
</div>
</details>
`;
})}
</div>
`
}