mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(ui): add Agents dashboard
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
114
ui/src/ui/controllers/agent-files.ts
Normal file
114
ui/src/ui/controllers/agent-files.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
ui/src/ui/controllers/agent-identity.ts
Normal file
59
ui/src/ui/controllers/agent-identity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
ui/src/ui/controllers/agent-skills.ts
Normal file
33
ui/src/ui/controllers/agent-skills.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
1950
ui/src/ui/views/agents.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user