feat(ui): add Agents dashboard

main
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 ### 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: seed zh-CN translations. (#6619) Thanks @joshp123.
- Docs: expand zh-Hans navigation and fix zh-CN index asset paths. (#7242) 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. - Docs: add zh-CN landing notice + AI-translated image. (#7303) Thanks @joshp123.

View File

@ -19,6 +19,7 @@ type ResolvedAgentConfig = {
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: AgentEntry["model"]; model?: AgentEntry["model"];
skills?: AgentEntry["skills"];
memorySearch?: AgentEntry["memorySearch"]; memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"]; humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"]; heartbeat?: AgentEntry["heartbeat"];
@ -112,6 +113,7 @@ export function resolveAgentConfig(
typeof entry.model === "string" || (entry.model && typeof entry.model === "object") typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
? entry.model ? entry.model
: undefined, : undefined,
skills: Array.isArray(entry.skills) ? entry.skills : undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat, 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 { export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model; const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) { if (!raw) {

View File

@ -4,6 +4,7 @@ import {
resolveAgentDir, resolveAgentDir,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
resolveSessionAgentId, resolveSessionAgentId,
resolveAgentSkillsFilter,
} from "../../agents/agent-scope.js"; } from "../../agents/agent-scope.js";
import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
@ -24,6 +25,31 @@ import { initSessionState } from "./session.js";
import { stageSandboxMedia } from "./stage-sandbox-media.js"; import { stageSandboxMedia } from "./stage-sandbox-media.js";
import { createTypingController } from "./typing.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( export async function getReplyFromConfig(
ctx: MsgContext, ctx: MsgContext,
opts?: GetReplyOptions, opts?: GetReplyOptions,
@ -38,6 +64,12 @@ export async function getReplyFromConfig(
sessionKey: agentSessionKey, sessionKey: agentSessionKey,
config: cfg, config: cfg,
}); });
const mergedSkillFilter = mergeSkillFilters(
opts?.skillFilter,
resolveAgentSkillsFilter(cfg, agentId),
);
const resolvedOpts =
mergedSkillFilter !== undefined ? { ...opts, skillFilter: mergedSkillFilter } : opts;
const agentCfg = cfg.agents?.defaults; const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
@ -164,8 +196,8 @@ export async function getReplyFromConfig(
provider, provider,
model, model,
typing, typing,
opts, opts: resolvedOpts,
skillFilter: opts?.skillFilter, skillFilter: mergedSkillFilter,
}); });
if (directiveResult.kind === "reply") { if (directiveResult.kind === "reply") {
return directiveResult.reply; return directiveResult.reply;
@ -216,7 +248,7 @@ export async function getReplyFromConfig(
sessionScope, sessionScope,
workspaceDir, workspaceDir,
isGroup, isGroup,
opts, opts: resolvedOpts,
typing, typing,
allowTextCommands, allowTextCommands,
inlineStatusRequested, inlineStatusRequested,
@ -238,7 +270,7 @@ export async function getReplyFromConfig(
contextTokens, contextTokens,
directiveAck, directiveAck,
abortedLastRun, abortedLastRun,
skillFilter: opts?.skillFilter, skillFilter: mergedSkillFilter,
}); });
if (inlineActionResult.kind === "reply") { if (inlineActionResult.kind === "reply") {
return inlineActionResult.reply; return inlineActionResult.reply;
@ -284,7 +316,7 @@ export async function getReplyFromConfig(
perMessageQueueMode, perMessageQueueMode,
perMessageQueueOptions, perMessageQueueOptions,
typing, typing,
opts, opts: resolvedOpts,
defaultProvider, defaultProvider,
defaultModel, defaultModel,
timeoutMs, timeoutMs,

View File

@ -4,6 +4,7 @@ import {
resolveAgentDir, resolveAgentDir,
resolveAgentModelFallbacksOverride, resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary, resolveAgentModelPrimary,
resolveAgentSkillsFilter,
resolveAgentWorkspaceDir, resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
@ -187,11 +188,13 @@ export async function agentCommand(
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId);
const skillsSnapshot = needsSkillsSnapshot const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { ? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg, config: cfg,
eligibility: { remote: getRemoteSkillEligibility() }, eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion: skillsSnapshotVersion, snapshotVersion: skillsSnapshotVersion,
skillFilter,
}) })
: sessionEntry?.skillsSnapshot; : sessionEntry?.skillsSnapshot;

View File

@ -124,6 +124,7 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.identity.avatar": "Identity Avatar",
"agents.list.*.skills": "Agent Skill Filter",
"gateway.remote.url": "Remote Gateway URL", "gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@ -346,6 +347,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.mattermost.requireMention": "Mattermost Require Mention", "channels.mattermost.requireMention": "Mattermost Require Mention",
"channels.signal.account": "Signal Account", "channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path", "channels.imessage.cliPath": "iMessage CLI Path",
"agents.list[].skills": "Agent Skill Filter",
"agents.list[].identity.avatar": "Agent Avatar", "agents.list[].identity.avatar": "Agent Avatar",
"discovery.mdns.mode": "mDNS Discovery Mode", "discovery.mdns.mode": "mDNS Discovery Mode",
"plugins.enabled": "Enable Plugins", "plugins.enabled": "Enable Plugins",
@ -377,6 +379,10 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.sshTarget": "gateway.remote.sshTarget":
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "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).", "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": "agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
"discovery.mdns.mode": "discovery.mdns.mode":

View File

@ -24,6 +24,8 @@ export type AgentConfig = {
workspace?: string; workspace?: string;
agentDir?: string; agentDir?: string;
model?: AgentModelConfig; model?: AgentModelConfig;
/** Optional allowlist of skills for this agent (omit = all skills; empty = none). */
skills?: string[];
memorySearch?: MemorySearchConfig; memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */ /** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig; humanDelay?: HumanDelayConfig;

View File

@ -427,6 +427,7 @@ export const AgentEntrySchema = z
workspace: z.string().optional(), workspace: z.string().optional(),
agentDir: z.string().optional(), agentDir: z.string().optional(),
model: AgentModelSchema.optional(), model: AgentModelSchema.optional(),
skills: z.array(z.string()).optional(),
memorySearch: MemorySearchSchema, memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(), humanDelay: HumanDelaySchema.optional(),
heartbeat: HeartbeatSchema, heartbeat: HeartbeatSchema,

View File

@ -6,6 +6,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200; const MAX_ASSISTANT_AVATAR = 200;
const MAX_ASSISTANT_EMOJI = 16;
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = { export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
agentId: "main", agentId: "main",
@ -17,6 +18,7 @@ export type AssistantIdentity = {
agentId: string; agentId: string;
name: string; name: string;
avatar: string; avatar: string;
emoji?: string;
}; };
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
@ -64,6 +66,33 @@ function normalizeAvatarValue(value: string | undefined): string | undefined {
return 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: { export function resolveAssistantIdentity(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
agentId?: string | null; agentId?: string | null;
@ -92,5 +121,13 @@ export function resolveAssistantIdentity(params: {
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ?? avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
DEFAULT_ASSISTANT_IDENTITY.avatar; 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, AgentParamsSchema,
type AgentSummary, type AgentSummary,
AgentSummarySchema, AgentSummarySchema,
type AgentsFileEntry,
AgentsFileEntrySchema,
type AgentsFilesGetParams,
AgentsFilesGetParamsSchema,
type AgentsFilesGetResult,
AgentsFilesGetResultSchema,
type AgentsFilesListParams,
AgentsFilesListParamsSchema,
type AgentsFilesListResult,
AgentsFilesListResultSchema,
type AgentsFilesSetParams,
AgentsFilesSetParamsSchema,
type AgentsFilesSetResult,
AgentsFilesSetResultSchema,
type AgentsListParams, type AgentsListParams,
AgentsListParamsSchema, AgentsListParamsSchema,
type AgentsListResult, type AgentsListResult,
@ -209,6 +223,15 @@ export const validateAgentIdentityParams =
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema); export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema); export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema); 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>( export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema, NodePairRequestParamsSchema,
); );
@ -408,6 +431,13 @@ export {
WebLoginStartParamsSchema, WebLoginStartParamsSchema,
WebLoginWaitParamsSchema, WebLoginWaitParamsSchema,
AgentSummarySchema, AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema, AgentsListParamsSchema,
AgentsListResultSchema, AgentsListResultSchema,
ModelsListParamsSchema, ModelsListParamsSchema,
@ -482,6 +512,13 @@ export type {
WebLoginStartParams, WebLoginStartParams,
WebLoginWaitParams, WebLoginWaitParams,
AgentSummary, AgentSummary,
AgentsFileEntry,
AgentsFilesListParams,
AgentsFilesListResult,
AgentsFilesGetParams,
AgentsFilesGetResult,
AgentsFilesSetParams,
AgentsFilesSetResult,
AgentsListParams, AgentsListParams,
AgentsListResult, AgentsListResult,
SkillsStatusParams, SkillsStatusParams,

View File

@ -84,6 +84,7 @@ export const AgentIdentityResultSchema = Type.Object(
agentId: NonEmptyString, agentId: NonEmptyString,
name: Type.Optional(NonEmptyString), name: Type.Optional(NonEmptyString),
avatar: Type.Optional(NonEmptyString), avatar: Type.Optional(NonEmptyString),
emoji: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@ -44,6 +44,70 @@ export const AgentsListResultSchema = Type.Object(
{ additionalProperties: false }, { 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 ModelsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const ModelsListResultSchema = Type.Object( export const ModelsListResultSchema = Type.Object(
@ -53,7 +117,12 @@ export const ModelsListResultSchema = Type.Object(
{ additionalProperties: false }, { 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 }); export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false });

View File

@ -11,6 +11,13 @@ import {
} from "./agent.js"; } from "./agent.js";
import { import {
AgentSummarySchema, AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema, AgentsListParamsSchema,
AgentsListResultSchema, AgentsListResultSchema,
ModelChoiceSchema, ModelChoiceSchema,
@ -182,6 +189,13 @@ export const ProtocolSchemas: Record<string, TSchema> = {
WebLoginStartParams: WebLoginStartParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema, AgentSummary: AgentSummarySchema,
AgentsFileEntry: AgentsFileEntrySchema,
AgentsFilesListParams: AgentsFilesListParamsSchema,
AgentsFilesListResult: AgentsFilesListResultSchema,
AgentsFilesGetParams: AgentsFilesGetParamsSchema,
AgentsFilesGetResult: AgentsFilesGetResultSchema,
AgentsFilesSetParams: AgentsFilesSetParamsSchema,
AgentsFilesSetResult: AgentsFilesSetResultSchema,
AgentsListParams: AgentsListParamsSchema, AgentsListParams: AgentsListParamsSchema,
AgentsListResult: AgentsListResultSchema, AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema, ModelChoice: ModelChoiceSchema,

View File

@ -9,6 +9,13 @@ import type {
} from "./agent.js"; } from "./agent.js";
import type { import type {
AgentSummarySchema, AgentSummarySchema,
AgentsFileEntrySchema,
AgentsFilesGetParamsSchema,
AgentsFilesGetResultSchema,
AgentsFilesListParamsSchema,
AgentsFilesListResultSchema,
AgentsFilesSetParamsSchema,
AgentsFilesSetResultSchema,
AgentsListParamsSchema, AgentsListParamsSchema,
AgentsListResultSchema, AgentsListResultSchema,
ModelChoiceSchema, ModelChoiceSchema,
@ -171,6 +178,13 @@ export type ChannelsLogoutParams = Static<typeof ChannelsLogoutParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>; export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>; export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>; 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 AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>; export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>; export type ModelChoice = Static<typeof ModelChoiceSchema>;

View File

@ -32,6 +32,9 @@ const BASE_METHODS = [
"talk.mode", "talk.mode",
"models.list", "models.list",
"agents.list", "agents.list",
"agents.files.list",
"agents.files.get",
"agents.files.set",
"skills.status", "skills.status",
"skills.bins", "skills.bins",
"skills.install", "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 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 { loadConfig } from "../../config/config.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateAgentsFilesGetParams,
validateAgentsFilesListParams,
validateAgentsFilesSetParams,
validateAgentsListParams, validateAgentsListParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.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 = { export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => { "agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) { if (!validateAgentsListParams(params)) {
@ -26,4 +141,143 @@ export const agentsHandlers: GatewayRequestHandlers = {
const result = listAgentsForGateway(cfg); const result = listAgentsForGateway(cfg);
respond(true, result, undefined); 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 { OpenClawConfig } from "../../config/config.js";
import type { GatewayRequestHandlers } from "./types.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 { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { loadConfig, writeConfigFile } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { import {
ErrorCodes, ErrorCodes,
errorShape, errorShape,
@ -75,7 +80,20 @@ export const skillsHandlers: GatewayRequestHandlers = {
return; return;
} }
const cfg = loadConfig(); 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, { const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg, config: cfg,
eligibility: { remote: getRemoteSkillEligibility() }, eligibility: { remote: getRemoteSkillEligibility() },

View File

@ -989,6 +989,7 @@
white-space: pre-wrap; white-space: pre-wrap;
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
@ -1496,3 +1497,398 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; 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 { .shell--chat-focus .content {
padding-top: 0; padding-top: 0;
gap: 0; }
.shell--chat-focus .content > * + * {
margin-top: 0;
} }
/* =========================================== /* ===========================================
@ -418,23 +421,32 @@
.content { .content {
grid-area: content; grid-area: content;
padding: 12px 16px 32px; padding: 12px 16px 32px;
display: flex; display: block;
flex-direction: column;
gap: 24px;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.content > * + * {
margin-top: 24px;
}
:root[data-theme="light"] .content { :root[data-theme="light"] .content {
background: var(--bg-content); background: var(--bg-content);
} }
.content--chat { .content--chat {
display: flex;
flex-direction: column;
gap: 24px;
overflow: hidden; overflow: hidden;
padding-bottom: 0; padding-bottom: 0;
} }
.content--chat > * + * {
margin-top: 0;
}
/* Content header */ /* Content header */
.content-header { .content-header {
display: flex; display: flex;

View File

@ -3,6 +3,10 @@ import type { AppViewState } from "./app-view-state";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { refreshChatAvatar } from "./app-chat"; import { refreshChatAvatar } from "./app-chat";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; 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 { loadChannels } from "./controllers/channels";
import { loadChatHistory } from "./controllers/chat"; import { loadChatHistory } from "./controllers/chat";
import { import {
@ -47,6 +51,7 @@ import {
} from "./controllers/skills"; } from "./controllers/skills";
import { icons } from "./icons"; import { icons } from "./icons";
import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation"; import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
import { renderAgents } from "./views/agents";
import { renderChannels } from "./views/channels"; import { renderChannels } from "./views/channels";
import { renderChat } from "./views/chat"; import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config"; import { renderConfig } from "./views/config";
@ -90,6 +95,13 @@ export function renderApp(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; 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` return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"> <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 : 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" state.tab === "skills"
? renderSkills({ ? renderSkills({

View File

@ -7,6 +7,9 @@ import {
stopDebugPolling, stopDebugPolling,
} from "./app-polling"; } from "./app-polling";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; 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 { loadChannels } from "./controllers/channels";
import { loadConfig, loadConfigSchema } from "./controllers/config"; import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron"; import { loadCronJobs, loadCronStatus } from "./controllers/cron";
@ -185,6 +188,28 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "skills") { if (host.tab === "skills") {
await loadSkills(host as unknown as OpenClawApp); 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") { if (host.tab === "nodes") {
await loadNodes(host as unknown as OpenClawApp); await loadNodes(host as unknown as OpenClawApp);
await loadDevices(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 { ThemeTransitionContext } from "./theme-transition";
import type { import type {
AgentsListResult, AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
ChannelsStatusSnapshot, ChannelsStatusSnapshot,
ConfigSnapshot, ConfigSnapshot,
CronJob, CronJob,
@ -106,6 +108,22 @@ export type AppViewState = {
agentsLoading: boolean; agentsLoading: boolean;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsError: string | 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; sessionsLoading: boolean;
sessionsResult: SessionsListResult | null; sessionsResult: SessionsListResult | null;
sessionsError: string | 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 { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { SkillMessage } from "./controllers/skills";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation"; import type { Tab } from "./navigation";
import type { ResolvedTheme, ThemeMode } from "./theme"; import type { ResolvedTheme, ThemeMode } from "./theme";
import type { import type {
AgentsListResult, AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
ConfigSnapshot, ConfigSnapshot,
ConfigUiHints, ConfigUiHints,
CronJob, CronJob,
@ -197,6 +200,23 @@ export class OpenClawApp extends LitElement {
@state() agentsLoading = false; @state() agentsLoading = false;
@state() agentsList: AgentsListResult | null = null; @state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | 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() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null; @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"; import { customElement, property } from "lit/decorators.js";
/** /**
@ -44,7 +44,7 @@ export class ResizableDivider extends LitElement {
`; `;
render() { render() {
return html``; return nothing;
} }
connectedCallback() { 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; agentsLoading: boolean;
agentsError: string | null; agentsError: string | null;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
}; };
export async function loadAgents(state: AgentsState) { export async function loadAgents(state: AgentsState) {
@ -22,6 +23,11 @@ export async function loadAgents(state: AgentsState) {
const res = await state.client.request("agents.list", {}); const res = await state.client.request("agents.list", {});
if (res) { if (res) {
state.agentsList = 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) { } catch (err) {
state.agentsError = String(err); state.agentsError = String(err);

View File

@ -6,11 +6,12 @@ export const TAB_GROUPS = [
label: "Control", label: "Control",
tabs: ["overview", "channels", "instances", "sessions", "cron"], tabs: ["overview", "channels", "instances", "sessions", "cron"],
}, },
{ label: "Agent", tabs: ["skills", "nodes"] }, { label: "Agent", tabs: ["agents", "skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] }, { label: "Settings", tabs: ["config", "debug", "logs"] },
] as const; ] as const;
export type Tab = export type Tab =
| "agents"
| "overview" | "overview"
| "channels" | "channels"
| "instances" | "instances"
@ -24,6 +25,7 @@ export type Tab =
| "logs"; | "logs";
const TAB_PATHS: Record<Tab, string> = { const TAB_PATHS: Record<Tab, string> = {
agents: "/agents",
overview: "/overview", overview: "/overview",
channels: "/channels", channels: "/channels",
instances: "/instances", instances: "/instances",
@ -120,6 +122,8 @@ export function inferBasePathFromPathname(pathname: string): string {
export function iconForTab(tab: Tab): IconName { export function iconForTab(tab: Tab): IconName {
switch (tab) { switch (tab) {
case "agents":
return "folder";
case "chat": case "chat":
return "messageSquare"; return "messageSquare";
case "overview": case "overview":
@ -149,6 +153,8 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) { export function titleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "agents":
return "Agents";
case "overview": case "overview":
return "Overview"; return "Overview";
case "channels": case "channels":
@ -178,6 +184,8 @@ export function titleForTab(tab: Tab) {
export function subtitleForTab(tab: Tab) { export function subtitleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "agents":
return "Manage agent workspaces, tools, and identities.";
case "overview": case "overview":
return "Gateway status, entry points, and a fast health read."; return "Gateway status, entry points, and a fast health read.";
case "channels": case "channels":

View File

@ -340,6 +340,41 @@ export type AgentsListResult = {
agents: GatewayAgentRow[]; 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 = { export type GatewaySessionRow = {
key: string; key: string;
kind: "direct" | "group" | "global" | "unknown"; 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 ?? {}; 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) { export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema); const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema; const normalized = analysis.schema;
@ -92,6 +132,7 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>
${renderExtraChannelFields(value)}
`; `;
} }

View File

@ -3,6 +3,42 @@ import type { SkillMessageMap } from "../controllers/skills";
import type { SkillStatusEntry, SkillStatusReport } from "../types"; import type { SkillStatusEntry, SkillStatusReport } from "../types";
import { clampText } from "../format"; 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 = { export type SkillsProps = {
loading: boolean; loading: boolean;
report: SkillStatusReport | null; report: SkillStatusReport | null;
@ -27,6 +63,7 @@ export function renderSkills(props: SkillsProps) {
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
) )
: skills; : skills;
const groups = groupSkills(filtered);
return html` return html`
<section class="card"> <section class="card">
@ -64,8 +101,21 @@ export function renderSkills(props: SkillsProps) {
<div class="muted" style="margin-top: 16px">No skills found.</div> <div class="muted" style="margin-top: 16px">No skills found.</div>
` `
: html` : html`
<div class="list" style="margin-top: 16px;"> <div class="agent-skills-groups" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))} ${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> </div>
` `
} }