diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 974670957b57..36d84299f1ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,7 +342,7 @@ jobs: run: ${{ matrix.command }} macos-app: - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.repository_owner == 'openclaw' runs-on: macos-latest strategy: fail-fast: false diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..ca24bf7aa211 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,30 @@ +name: Claude Code + +on: + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + +jobs: + claude: + runs-on: ubuntu-latest + # Only run when @claude is mentioned in issues/comments + if: contains(github.event.comment.body, '@claude') || contains(github.event.issue.body, '@claude') + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Claude Code + uses: anthropics/claude-code-action@v1 + with: + prompt: | + ${{ github.event.comment.body || github.event.issue.body }} + claude_args: | + --max-turns 10 + env: + # Use OAuth token for Claude Max/Pro subscription (no API key needed) + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f403c1030c02..1368bbaf45c5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,6 +10,7 @@ permissions: {} jobs: label: + if: github.repository_owner == 'openclaw' && secrets.GH_APP_PRIVATE_KEY != '' permissions: contents: read pull-requests: write @@ -48,6 +49,7 @@ jobs: }); label-issues: + if: github.repository_owner == 'openclaw' && secrets.GH_APP_PRIVATE_KEY != '' permissions: issues: write runs-on: ubuntu-latest diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 5e4def80fa2e..47ecbb1cd563 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -6,8 +6,8 @@ * Provides seamless auto-recall and auto-capture via lifecycle hooks. */ +import type * as LanceDB from "@lancedb/lancedb"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import * as lancedb from "@lancedb/lancedb"; import { Type } from "@sinclair/typebox"; import { randomUUID } from "node:crypto"; import OpenAI from "openai"; @@ -44,8 +44,9 @@ type MemorySearchResult = { const TABLE_NAME = "memories"; class MemoryDB { - private db: lancedb.Connection | null = null; - private table: lancedb.Table | null = null; + private lancedb: typeof import("@lancedb/lancedb") | null = null; + private db: LanceDB.Connection | null = null; + private table: LanceDB.Table | null = null; private initPromise: Promise | null = null; constructor( @@ -65,7 +66,21 @@ class MemoryDB { return this.initPromise; } + private async getLanceDB(): Promise { + if (this.lancedb) return this.lancedb; + try { + this.lancedb = await import("@lancedb/lancedb"); + return this.lancedb; + } catch (err) { + // LanceDB currently ships platform-specific native deps. Fail gracefully on unsupported platforms. + throw new Error( + `memory-lancedb: LanceDB native module unavailable on ${process.platform}/${process.arch}: ${String(err)}`, + ); + } + } + private async doInitialize(): Promise { + const lancedb = await this.getLanceDB(); this.db = await lancedb.connect(this.dbPath); const tables = await this.db.tableNames(); diff --git a/hooks/logs/hooks.jsonl b/hooks/logs/hooks.jsonl new file mode 100644 index 000000000000..52b9b613c819 --- /dev/null +++ b/hooks/logs/hooks.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-02-07T03:11:12.846Z","level":"debug","hook":"voice-macos","event":"voice","message":"Speech completed"} diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index e5774fba724f..35d0f12ac6fc 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -12,6 +12,9 @@ export type ChannelUiMetaEntry = { label: string; detailLabel: string; systemImage?: string; + description?: string; + docsPath?: string; + docsLabel?: string; }; export type ChannelUiCatalog = { @@ -242,6 +245,9 @@ export function buildChannelUiCatalog( label: plugin.meta.label, detailLabel, ...(plugin.meta.systemImage ? { systemImage: plugin.meta.systemImage } : {}), + ...(plugin.meta.blurb ? { description: plugin.meta.blurb } : {}), + ...(plugin.meta.docsPath ? { docsPath: plugin.meta.docsPath } : {}), + ...(plugin.meta.docsLabel ? { docsLabel: plugin.meta.docsLabel } : {}), }; }); const order = entries.map((entry) => entry.id); diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 044cbd5864d0..306aed15c79b 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -33,10 +33,32 @@ import type { export type ChannelConfigUiHint = { label?: string; help?: string; + group?: string; + order?: number; advanced?: boolean; sensitive?: boolean; placeholder?: string; itemTemplate?: unknown; + docsPath?: string; + impacts?: Array<{ + relation?: "requires" | "conflicts" | "recommends" | "risk"; + targetPath?: string; + when?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + whenValue?: unknown; + targetWhen?: + | "truthy" + | "falsy" + | "defined" + | "notDefined" + | "equals" + | "notEquals" + | "includes"; + targetValue?: unknown; + message: string; + fixValue?: unknown; + fixLabel?: string; + docsPath?: string; + }>; }; export type ChannelConfigSchema = { diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 9cdcf18652a1..42ab87c3da7d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -39,7 +39,7 @@ export async function runGatewayLoop(params: { gatewayLog.error("shutdown timed out; exiting without full cleanup"); cleanupSignals(); params.runtime.exit(0); - }, 5000); + }, 15_000); void (async () => { try { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index f7f90f37e47e..24b928fd70e5 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -101,4 +101,30 @@ describe("config schema", () => { expect(defaultsHint?.help).toContain("last"); expect(listHint?.help).toContain("bluebubbles"); }); + + it("adds docs and impact metadata for high-risk settings", () => { + const res = buildConfigSchema({ + channels: [ + { + id: "discord", + label: "Discord", + description: "Discord bot channel", + docsPath: "/channels/discord", + configSchema: { type: "object" }, + }, + ], + }); + + expect(res.uiHints["gateway.mode"]?.docsPath).toBe("/web/dashboard"); + expect(res.uiHints["gateway.mode"]?.impacts?.length).toBeGreaterThan(0); + expect(res.uiHints["channels.discord"]?.docsPath).toBe("/channels/discord"); + expect(res.uiHints["channels.discord.actions.roles"]?.docsPath).toBe("/channels/discord"); + expect(res.uiHints["channels.discord.actions.roles"]?.impacts?.[0]?.relation).toBe("requires"); + expect(res.uiHints["channels.*.allowBots"]?.docsPath).toBe("/gateway/configuration"); + expect(res.uiHints["channels.*.allowBots"]?.impacts?.[0]?.relation).toBe("risk"); + expect(res.uiHints["channels.*.dm.policy"]?.impacts?.[0]?.targetPath).toBe( + "channels.*.dm.allowFrom", + ); + expect(res.uiHints["channels.telegram.streamMode"]?.impacts?.[0]?.relation).toBe("conflicts"); + }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index a9c177c82490..34d6baa1b326 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -11,6 +11,21 @@ export type ConfigUiHint = { sensitive?: boolean; placeholder?: string; itemTemplate?: unknown; + docsPath?: string; + impacts?: ConfigUiHintImpact[]; +}; + +export type ConfigUiHintImpact = { + relation?: "requires" | "conflicts" | "recommends" | "risk"; + targetPath?: string; + when?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + whenValue?: unknown; + targetWhen?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + targetValue?: unknown; + message: string; + fixValue?: unknown; + fixLabel?: string; + docsPath?: string; }; export type ConfigUiHints = Record; @@ -32,7 +47,19 @@ export type PluginUiMetadata = { description?: string; configUiHints?: Record< string, - Pick + Pick< + ConfigUiHint, + | "label" + | "help" + | "group" + | "order" + | "advanced" + | "sensitive" + | "placeholder" + | "itemTemplate" + | "docsPath" + | "impacts" + > >; configSchema?: JsonSchemaNode; }; @@ -41,6 +68,7 @@ export type ChannelUiMetadata = { id: string; label?: string; description?: string; + docsPath?: string; configSchema?: JsonSchemaNode; configUiHints?: Record; }; @@ -125,6 +153,9 @@ const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", "agents.list.*.identity.avatar": "Identity Avatar", "agents.list.*.skills": "Agent Skill Filter", + "gateway.mode": "Gateway Mode", + "gateway.port": "Gateway Port", + "gateway.bind": "Bind Address", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -503,6 +534,38 @@ const FIELD_HELP: Record = { "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.*.allowBots": + "Allow replies to bot-authored messages for this channel (default: false). Keep mention/allowlist guardrails to avoid bot loops.", + "channels.*.accounts.*.allowBots": + "Allow replies to bot-authored messages for this account (default: false). Keep mention/allowlist guardrails to avoid bot loops.", + "channels.*.blockStreaming": + "Send completed reply blocks while the model is still generating for this channel/account.", + "channels.*.accounts.*.blockStreaming": + "Send completed reply blocks while the model is still generating for this account.", + "channels.*.capabilities": + "Optional runtime capability tags. Leave empty unless a feature/docs explicitly require a tag.", + "channels.*.accounts.*.capabilities": + "Optional runtime capability tags for this account. Leave empty unless a feature/docs explicitly require a tag.", + "channels.*.dm.policy": + 'Direct message access policy for this channel account. "open" requires allowFrom to include "*".', + "channels.*.accounts.*.dm.policy": + 'Direct message access policy for this account. "open" requires allowFrom to include "*".', + "channels.*.dmPolicy": + 'Direct message access policy for this channel. "open" requires allowFrom to include "*".', + "channels.*.accounts.*.dmPolicy": + 'Direct message access policy for this account. "open" requires allowFrom to include "*".', + "channels.discord.groupPolicy": + 'Guild message policy. "allowlist" requires guild entries under channels.discord.guilds.', + "channels.discord.accounts.*.groupPolicy": + 'Guild message policy for this account. "allowlist" requires guild entries under channels.discord.accounts..guilds.', + "channels.slack.groupPolicy": + 'Channel message policy. "allowlist" requires channel entries under channels.slack.channels.', + "channels.slack.accounts.*.groupPolicy": + 'Channel message policy for this account. "allowlist" requires channel entries under channels.slack.accounts..channels.', + "channels.googlechat.groupPolicy": + 'Space message policy. "allowlist" requires space entries under channels.googlechat.groups.', + "channels.googlechat.accounts.*.groupPolicy": + 'Space message policy for this account. "allowlist" requires space entries under channels.googlechat.accounts..groups.', "channels.slack.thread.historyScope": 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', "channels.slack.thread.inheritParent": @@ -750,8 +813,251 @@ const FIELD_HELP: Record = { "Resolve PluralKit proxied messages and treat system members as distinct senders.", "channels.discord.pluralkit.token": "Optional PluralKit token for resolving private systems or members.", + + // --- discord actions (top-level + per-account wildcard) --- + "channels.discord.actions.reactions": + "Allow the agent to add, remove, and list emoji reactions on Discord messages. Default: on.", + "channels.discord.actions.stickers": + "Allow the agent to send sticker messages in Discord channels. Default: on.", + "channels.discord.actions.emojiUploads": + "Allow the agent to upload custom emoji to the server. Default: on.", + "channels.discord.actions.stickerUploads": + "Allow the agent to upload custom stickers to the server. Default: on.", + "channels.discord.actions.polls": + "Allow the agent to create polls in Discord channels. Default: on.", + "channels.discord.actions.permissions": + "Allow the agent to view and manage channel permissions. Default: on.", + "channels.discord.actions.messages": + "Allow the agent to read, edit, and delete messages in Discord channels. Default: on.", + "channels.discord.actions.threads": + "Allow the agent to create, list, and reply in threads. Default: on.", + "channels.discord.actions.pins": + "Allow the agent to pin, unpin, and list pinned messages. Default: on.", + "channels.discord.actions.search": + "Allow the agent to search message history in Discord channels. Default: on.", + "channels.discord.actions.memberInfo": + "Allow the agent to look up member profiles, roles, and join dates. Default: on.", + "channels.discord.actions.roleInfo": + "Allow the agent to view role details (name, color, permissions). Default: on.", + "channels.discord.actions.roles": + "Allow the agent to assign and remove roles from members. Default: off — enable with caution.", + "channels.discord.actions.channelInfo": + "Allow the agent to read channel names, topics, and categories. Default: on.", + "channels.discord.actions.channels": + "Allow the agent to create, edit, delete, and move channels and categories. Default: on.", + "channels.discord.actions.voiceStatus": + "Allow the agent to view voice channel status (who is connected). Default: on.", + "channels.discord.actions.events": + "Allow the agent to list and create scheduled events in the server. Default: on.", + "channels.discord.actions.moderation": + "Allow the agent to timeout, kick, and ban members. Default: off — enable with caution.", + "channels.discord.actions.presence": + "Allow the agent to set its own presence/status (online, idle, DND). Default: off.", + + // per-account discord action hints (wildcard) + "channels.discord.accounts.*.actions.reactions": + "Allow the agent to add, remove, and list emoji reactions on Discord messages. Default: on.", + "channels.discord.accounts.*.actions.stickers": + "Allow the agent to send sticker messages in Discord channels. Default: on.", + "channels.discord.accounts.*.actions.emojiUploads": + "Allow the agent to upload custom emoji to the server. Default: on.", + "channels.discord.accounts.*.actions.stickerUploads": + "Allow the agent to upload custom stickers to the server. Default: on.", + "channels.discord.accounts.*.actions.polls": + "Allow the agent to create polls in Discord channels. Default: on.", + "channels.discord.accounts.*.actions.permissions": + "Allow the agent to view and manage channel permissions. Default: on.", + "channels.discord.accounts.*.actions.messages": + "Allow the agent to read, edit, and delete messages in Discord channels. Default: on.", + "channels.discord.accounts.*.actions.threads": + "Allow the agent to create, list, and reply in threads. Default: on.", + "channels.discord.accounts.*.actions.pins": + "Allow the agent to pin, unpin, and list pinned messages. Default: on.", + "channels.discord.accounts.*.actions.search": + "Allow the agent to search message history in Discord channels. Default: on.", + "channels.discord.accounts.*.actions.memberInfo": + "Allow the agent to look up member profiles, roles, and join dates. Default: on.", + "channels.discord.accounts.*.actions.roleInfo": + "Allow the agent to view role details (name, color, permissions). Default: on.", + "channels.discord.accounts.*.actions.roles": + "Allow the agent to assign and remove roles from members. Default: off — enable with caution.", + "channels.discord.accounts.*.actions.channelInfo": + "Allow the agent to read channel names, topics, and categories. Default: on.", + "channels.discord.accounts.*.actions.channels": + "Allow the agent to create, edit, delete, and move channels and categories. Default: on.", + "channels.discord.accounts.*.actions.voiceStatus": + "Allow the agent to view voice channel status (who is connected). Default: on.", + "channels.discord.accounts.*.actions.events": + "Allow the agent to list and create scheduled events in the server. Default: on.", + "channels.discord.accounts.*.actions.moderation": + "Allow the agent to timeout, kick, and ban members. Default: off — enable with caution.", + "channels.discord.accounts.*.actions.presence": + "Allow the agent to set its own presence/status (online, idle, DND). Default: off.", + "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', + + // --- gateway --- + "gateway.mode": + 'Gateway run mode ("local" for local-only, "remote" to connect to a remote gateway).', + "gateway.port": "Port for the gateway HTTP/WebSocket server (default: 18789).", + "gateway.bind": + 'Network interface to bind ("loopback" for 127.0.0.1, "all" for 0.0.0.0, or a specific address).', + + // --- diagnostics --- + "diagnostics.enabled": "Enable diagnostics subsystem (default: true).", + "diagnostics.otel.enabled": + "Enable OpenTelemetry export (traces, metrics, and/or logs). Requires an OTLP-compatible collector.", + "diagnostics.otel.endpoint": + "OTLP collector endpoint (e.g. http://localhost:4318 for HTTP or http://localhost:4317 for gRPC).", + "diagnostics.otel.protocol": 'OTLP transport protocol ("http/protobuf" or "grpc").', + "diagnostics.otel.headers": + "Extra headers sent with OTLP requests (key-value pairs for auth, routing, etc.).", + "diagnostics.otel.serviceName": + 'Service name reported to the collector (default: "openclaw-gateway").', + "diagnostics.otel.traces": + "Export distributed traces via OTLP (default: true when OTEL enabled).", + "diagnostics.otel.metrics": "Export metrics via OTLP (default: true when OTEL enabled).", + "diagnostics.otel.logs": "Export logs via OTLP (default: false).", + "diagnostics.otel.sampleRate": + "Trace sample rate (0.0-1.0). 1.0 traces everything; lower values reduce volume.", + "diagnostics.otel.flushIntervalMs": + "How often (ms) the OTLP exporter flushes buffered telemetry (default: 5000).", + + // --- tools.exec --- + "tools.exec.host": + 'Exec host environment ("local" runs commands on the gateway host; other values may route to sandboxes).', + "tools.exec.security": + 'Exec security mode ("elevated" allows all commands; "standard" restricts to safe bins and approved tools).', + "tools.exec.ask": + 'Exec ask/approval mode ("always" prompts before every exec; "auto" prompts only for unknown commands; "never" skips prompts).', + "tools.exec.node": "Node.js binary used for exec-based tools (default: auto-detected).", + "tools.exec.approvalRunningNoticeMs": + "Delay (ms) before showing a notice that an exec approval is pending (default: 5000).", + + // --- tools.profile / alsoAllow / byProvider --- + "tools.profile": + 'Active tool profile ("standard", "elevated", or "minimal"). Controls which tools are available by default.', + "tools.alsoAllow": + "Additional tools to allow on top of the active profile (array of tool names).", + "agents.list[].tools.profile": + 'Per-agent tool profile override ("standard", "elevated", or "minimal").', + "agents.list[].tools.alsoAllow": + "Per-agent additional tool allowlist (merged with the global alsoAllow).", + "tools.byProvider": + "Per-provider tool policy overrides (keyed by provider ID). Lets you enable/disable specific tools for certain providers.", + "agents.list[].tools.byProvider": "Per-agent, per-provider tool policy overrides.", + + // --- tools.media --- + "tools.media.image.enabled": + "Enable image understanding (vision). When enabled, images are analyzed and described for the agent.", + "tools.media.image.maxBytes": + "Maximum image file size (bytes) accepted for understanding (larger images are skipped).", + "tools.media.image.maxChars": + "Maximum characters in the image description returned to the agent.", + "tools.media.image.prompt": + "Custom prompt sent to the vision model when describing images (overrides the default).", + "tools.media.image.timeoutSeconds": "Timeout (seconds) for image understanding requests.", + "tools.media.image.attachments": + 'Attachment handling policy for images ("inline" sends image data with the message; "describe" sends only the text description; "skip" ignores).', + "tools.media.image.models": + "Model(s) to use for image understanding (overrides the shared tools.media.models).", + "tools.media.image.scope": + 'Channel scope for image understanding ("all", "direct", "group", or a channel ID list).', + "tools.media.models": + "Shared model list for all media understanding pipelines (image, audio, video). Per-type models override this.", + "tools.media.concurrency": + "Max concurrent media understanding jobs across all types (default: 2).", + "tools.media.audio.enabled": + "Enable audio understanding (speech-to-text transcription + analysis).", + "tools.media.audio.maxBytes": "Maximum audio file size (bytes) accepted for understanding.", + "tools.media.audio.maxChars": + "Maximum characters in the audio transcription returned to the agent.", + "tools.media.audio.prompt": + "Custom prompt sent to the audio model when transcribing audio (overrides the default).", + "tools.media.audio.timeoutSeconds": "Timeout (seconds) for audio understanding requests.", + "tools.media.audio.language": + 'Language hint for audio transcription (ISO 639-1 code, e.g. "en", "es", "zh").', + "tools.media.audio.attachments": + 'Attachment handling policy for audio ("inline", "describe", or "skip").', + "tools.media.audio.models": + "Model(s) to use for audio understanding (overrides the shared tools.media.models).", + "tools.media.audio.scope": + 'Channel scope for audio understanding ("all", "direct", "group", or a channel ID list).', + "tools.media.video.enabled": "Enable video understanding (frame extraction + analysis).", + "tools.media.video.maxBytes": "Maximum video file size (bytes) accepted for understanding.", + "tools.media.video.maxChars": + "Maximum characters in the video description returned to the agent.", + "tools.media.video.prompt": + "Custom prompt sent to the vision model when describing video frames (overrides the default).", + "tools.media.video.timeoutSeconds": "Timeout (seconds) for video understanding requests.", + "tools.media.video.attachments": + 'Attachment handling policy for videos ("inline", "describe", or "skip").', + "tools.media.video.models": + "Model(s) to use for video understanding (overrides the shared tools.media.models).", + "tools.media.video.scope": + 'Channel scope for video understanding ("all", "direct", "group", or a channel ID list).', + "tools.links.enabled": + "Enable link understanding (fetches URLs shared in messages and summarizes them).", + "tools.links.maxLinks": "Maximum number of links to process per message (default: 3).", + "tools.links.timeoutSeconds": "Timeout (seconds) for fetching and understanding links.", + "tools.links.models": "Model(s) to use for link understanding.", + "tools.links.scope": + 'Channel scope for link understanding ("all", "direct", "group", or a channel ID list).', + + // --- ui --- + "ui.seamColor": "Accent color used in the Control UI and CLI output (hex color code).", + "ui.assistant.name": "Display name for the assistant shown in the UI and message headers.", + "ui.assistant.avatar": "Avatar emoji or image URL for the assistant shown in the UI.", + + // --- browser --- + "browser.evaluateEnabled": + "Allow the browser_evaluate tool to run arbitrary JavaScript in the controlled browser (default: false).", + "browser.snapshotDefaults": + "Default settings for browser snapshots (screenshots and DOM captures).", + "browser.snapshotDefaults.mode": 'Default snapshot mode ("screenshot", "dom", or "hybrid").', + "browser.remoteCdpTimeoutMs": + "Timeout (ms) for connecting to a remote Chrome DevTools Protocol endpoint (default: 10000).", + "browser.remoteCdpHandshakeTimeoutMs": + "Timeout (ms) for the CDP WebSocket handshake after connection (default: 5000).", + + // --- talk --- + "talk.apiKey": + "API key for the Talk voice provider (e.g. OpenAI Realtime API key). Falls back to OPENAI_API_KEY env var.", + + // --- skills --- + "skills.load.watch": + "Watch skill directories for changes and hot-reload on save (default: true in dev).", + "skills.load.watchDebounceMs": + "Debounce window (ms) before reloading skills after a file change (default: 300).", + + // --- agents.defaults --- + "agents.defaults.workspace": + "Default workspace directory for agents (used for file operations and relative path resolution).", + "agents.defaults.memorySearch.enabled": + "Enable vector-based memory search for agents (default: true).", + "agents.defaults.memorySearch.model": + 'Embedding model for memory search (e.g. "text-embedding-3-small").', + "agents.defaults.memorySearch.chunking.tokens": + "Target chunk size in tokens for memory indexing (default: 256).", + "agents.defaults.memorySearch.chunking.overlap": + "Overlap tokens between consecutive memory chunks (default: 32).", + "agents.defaults.memorySearch.sync.onSessionStart": + "Trigger a memory reindex when a new session starts (default: true).", + "agents.defaults.memorySearch.sync.watchDebounceMs": + "Debounce window (ms) before reindexing after a memory file change (default: 1000).", + "agents.defaults.memorySearch.query.maxResults": + "Maximum number of memory search results returned per query (default: 5).", + "agents.defaults.memorySearch.query.minScore": + "Minimum similarity score (0-1) for memory search results to be included.", + + // --- channels (missing help) --- + "channels.telegram.capabilities.inlineButtons": + "Enable inline button support for Telegram messages (default: true). Disable to suppress inline keyboards.", + "channels.signal.account": + "Phone number registered with Signal for the bot (E.164 format, e.g. +15551234567). Used by signal-cli.", + "channels.imessage.cliPath": + "Path to the openclaw-imessage CLI binary used for sending/receiving iMessages.", }; const FIELD_PLACEHOLDERS: Record = { @@ -763,6 +1069,312 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.controlUi.allowedOrigins": "https://control.example.com", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", + "channels.telegram.botToken": "123456:ABC-DEF1234...", + "channels.discord.token": "MTk...", + "channels.slack.botToken": "xoxb-...", + "channels.slack.appToken": "xapp-...", + "channels.signal.account": "+15551234567", + "channels.imessage.cliPath": "/usr/local/bin/openclaw-imessage", + "tools.web.search.apiKey": "BSA...", + "talk.apiKey": "sk-...", + "agents.defaults.model.primary": "anthropic/claude-sonnet-4-5-20250929", + "ui.assistant.name": "Mr. Fox", + "ui.assistant.avatar": "\u{1F98A}", + "ui.seamColor": "#6366f1", +}; + +const FIELD_DOCS: Record = { + "gateway.mode": "/web/dashboard", + "gateway.bind": "/web/dashboard", + "gateway.controlUi.allowInsecureAuth": "/web/control-ui#insecure-http", + "channels.*.allowBots": "/gateway/configuration", + "channels.*.accounts.*.allowBots": "/gateway/configuration", + "channels.*.blockStreaming": "/concepts/streaming", + "channels.*.accounts.*.blockStreaming": "/concepts/streaming", + "channels.*.dm.policy": "/gateway/security", + "channels.*.accounts.*.dm.policy": "/gateway/security", + "channels.*.dmPolicy": "/gateway/security", + "channels.*.accounts.*.dmPolicy": "/gateway/security", + "channels.discord.groupPolicy": "/channels/discord", + "channels.discord.accounts.*.groupPolicy": "/channels/discord", + "channels.slack.groupPolicy": "/channels/slack", + "channels.slack.accounts.*.groupPolicy": "/channels/slack", + "channels.googlechat.groupPolicy": "/channels/googlechat", + "channels.googlechat.accounts.*.groupPolicy": "/channels/googlechat", + "channels.discord.actions.roles": "/channels/discord", + "channels.discord.actions.moderation": "/channels/discord", + "channels.discord.accounts.*.actions.roles": "/channels/discord", + "channels.discord.accounts.*.actions.moderation": "/channels/discord", +}; + +const FIELD_IMPACTS: Record = { + "gateway.mode": [ + { + relation: "requires", + when: "equals", + whenValue: "remote", + targetPath: "gateway.remote.url", + targetWhen: "defined", + message: "Remote mode requires a Remote Gateway URL.", + }, + { + relation: "recommends", + when: "equals", + whenValue: "remote", + targetPath: "gateway.remote.token", + targetWhen: "defined", + message: "Set a remote token (or password) to avoid unauthorized connection errors.", + }, + ], + "gateway.bind": [ + { + relation: "risk", + when: "notEquals", + whenValue: "loopback", + message: + "Non-loopback bind exposes the gateway over the network. Keep auth on and firewall access tightly.", + }, + ], + "gateway.controlUi.allowInsecureAuth": [ + { + relation: "risk", + when: "truthy", + message: + "Insecure auth allows token auth over plain HTTP. Prefer HTTPS (or loopback) for dashboard access.", + }, + ], + "channels.*.allowBots": [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Prefer mention/allowlist guardrails.", + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.allowBots": [ + { + relation: "risk", + when: "truthy", + message: + "Allowing bot-authored messages can create bot-to-bot reply loops. Prefer mention/allowlist guardrails.", + docsPath: "/gateway/security", + }, + ], + "channels.*.dm.policy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.dm.policy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.accounts.*.dm.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.dmPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.*.accounts.*.dmPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "open", + targetPath: "channels.*.accounts.*.allowFrom", + targetWhen: "includes", + targetValue: "*", + message: 'Open DM policy requires allowFrom to include "*".', + fixValue: ["*"], + fixLabel: 'Allow all ("*")', + docsPath: "/gateway/security", + }, + ], + "channels.discord.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.discord.guilds", + targetWhen: "defined", + message: 'Discord "allowlist" policy needs at least one guild entry.', + docsPath: "/channels/discord", + }, + ], + "channels.discord.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.discord.accounts.*.guilds", + targetWhen: "defined", + message: 'Discord "allowlist" policy needs at least one guild entry for this account.', + docsPath: "/channels/discord", + }, + ], + "channels.slack.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.slack.channels", + targetWhen: "defined", + message: 'Slack "allowlist" policy needs at least one channel entry.', + docsPath: "/channels/slack", + }, + ], + "channels.slack.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.slack.accounts.*.channels", + targetWhen: "defined", + message: 'Slack "allowlist" policy needs at least one channel entry for this account.', + docsPath: "/channels/slack", + }, + ], + "channels.googlechat.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.googlechat.groups", + targetWhen: "defined", + message: 'Google Chat "allowlist" policy needs at least one space entry.', + docsPath: "/channels/googlechat", + }, + ], + "channels.googlechat.accounts.*.groupPolicy": [ + { + relation: "requires", + when: "equals", + whenValue: "allowlist", + targetPath: "channels.googlechat.accounts.*.groups", + targetWhen: "defined", + message: 'Google Chat "allowlist" policy needs at least one space entry for this account.', + docsPath: "/channels/googlechat", + }, + ], + "channels.telegram.streamMode": [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Set streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.accounts.*.streamMode": [ + { + relation: "conflicts", + when: "notEquals", + whenValue: "off", + targetPath: "channels.telegram.accounts.*.blockStreaming", + targetWhen: "truthy", + message: + "Telegram draft streaming can suppress block streaming for a reply. Set streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.blockStreaming": [ + { + relation: "conflicts", + when: "truthy", + targetPath: "channels.telegram.streamMode", + targetWhen: "notEquals", + targetValue: "off", + message: + "Telegram streamMode is enabled. Use streamMode=off if you want block streaming to drive replies.", + docsPath: "/concepts/streaming", + }, + ], + "channels.telegram.accounts.*.blockStreaming": [ + { + relation: "conflicts", + when: "truthy", + targetPath: "channels.telegram.accounts.*.streamMode", + targetWhen: "notEquals", + targetValue: "off", + message: + "Telegram streamMode is enabled for this account. Use streamMode=off for block-only behavior.", + docsPath: "/concepts/streaming", + }, + ], + "channels.discord.actions.roles": [ + { + relation: "requires", + when: "truthy", + targetPath: "channels.discord.actions.permissions", + targetWhen: "truthy", + message: "Role management depends on permissions actions being enabled.", + fixValue: true, + fixLabel: "Enable permissions", + }, + ], + "channels.discord.actions.moderation": [ + { + relation: "requires", + when: "truthy", + targetPath: "channels.discord.actions.permissions", + targetWhen: "truthy", + message: "Moderation actions depend on permissions actions being enabled.", + fixValue: true, + fixLabel: "Enable permissions", + }, + ], + "channels.discord.accounts.*.actions.roles": [ + { + relation: "requires", + when: "truthy", + targetPath: "channels.discord.accounts.*.actions.permissions", + targetWhen: "truthy", + message: "Role management depends on permissions actions being enabled for this account.", + fixValue: true, + fixLabel: "Enable permissions", + }, + ], + "channels.discord.accounts.*.actions.moderation": [ + { + relation: "requires", + when: "truthy", + targetPath: "channels.discord.accounts.*.actions.permissions", + targetWhen: "truthy", + message: "Moderation actions depend on permissions actions being enabled for this account.", + fixValue: true, + fixLabel: "Enable permissions", + }, + ], }; const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; @@ -844,6 +1456,14 @@ function buildBaseHints(): ConfigUiHints { const current = hints[path]; hints[path] = current ? { ...current, placeholder } : { placeholder }; } + for (const [path, docsPath] of Object.entries(FIELD_DOCS)) { + const current = hints[path]; + hints[path] = current ? { ...current, docsPath } : { docsPath }; + } + for (const [path, impacts] of Object.entries(FIELD_IMPACTS)) { + const current = hints[path]; + hints[path] = current ? { ...current, impacts } : { impacts }; + } return hints; } @@ -911,10 +1531,12 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): const current = next[basePath] ?? {}; const label = channel.label?.trim(); const help = channel.description?.trim(); + const docsPath = channel.docsPath?.trim(); next[basePath] = { ...current, ...(label ? { label } : {}), ...(help ? { help } : {}), + ...(docsPath ? { docsPath } : {}), }; const uiHints = channel.configUiHints ?? {}; diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 3ec1c935b08f..d9e5496647ac 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -479,7 +479,15 @@ describe("runCronIsolatedAgentTurn", () => { sendMessageSignal: vi.fn(), sendMessageIMessage: vi.fn(), }; - vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 5, + providerModel: "test/model", + inputTokens: 10, + outputTokens: 5, + }, + }); const res = await runCronIsolatedAgentTurn({ cfg: makeCfg(home, storePath), @@ -494,9 +502,9 @@ describe("runCronIsolatedAgentTurn", () => { lane: "cron", }); - expect(res.status).toBe("error"); - expect(res.error).toMatch("invalid model"); - expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + // Invalid model override should warn and fall back to default, not fail + expect(res.status).toBe("ok"); + expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalled(); }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 3dd0cc416579..57b4bcf43710 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -200,10 +200,14 @@ export async function runCronIsolatedAgentTurn(params: { defaultModel: resolvedDefault.model, }); if ("error" in resolvedOverride) { - return { status: "error", error: resolvedOverride.error }; + // Fall through to the default model instead of failing the entire cron job + logWarn( + `[cron:${params.job.id}] model override "${modelOverrideRaw}" rejected (${resolvedOverride.error}); using default model`, + ); + } else { + provider = resolvedOverride.ref.provider; + model = resolvedOverride.ref.model; } - provider = resolvedOverride.ref.provider; - model = resolvedOverride.ref.model; } const now = Date.now(); const cronSession = resolveCronSession({ diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index e685cd9941cf..20f466072a82 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -106,5 +106,5 @@ export function buildLaunchAgentPlist({ ? `\n Comment\n ${plistEscape(comment.trim())}` : ""; const envXml = renderEnvDict(environment); - return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; + return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ThrottleInterval\n 10\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; } diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index cbbaaa1922c8..36700bfa369c 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -60,6 +60,9 @@ export const ChannelUiMetaSchema = Type.Object( label: NonEmptyString, detailLabel: NonEmptyString, systemImage: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + docsPath: Type.Optional(Type.String()), + docsLabel: Type.Optional(Type.String()), }, { additionalProperties: false }, ); diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index eb7389a4d1d0..264627f46cf1 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -47,6 +47,7 @@ export const UpdateRunParamsSchema = Type.Object( export const ConfigUiHintSchema = Type.Object( { + docsPath: Type.Optional(Type.String()), label: Type.Optional(Type.String()), help: Type.Optional(Type.String()), group: Type.Optional(Type.String()), @@ -55,6 +56,25 @@ export const ConfigUiHintSchema = Type.Object( sensitive: Type.Optional(Type.Boolean()), placeholder: Type.Optional(Type.String()), itemTemplate: Type.Optional(Type.Unknown()), + impacts: Type.Optional( + Type.Array( + Type.Object( + { + relation: Type.Optional(Type.String()), + targetPath: Type.Optional(Type.String()), + when: Type.Optional(Type.String()), + whenValue: Type.Optional(Type.Unknown()), + targetWhen: Type.Optional(Type.String()), + targetValue: Type.Optional(Type.Unknown()), + message: Type.String(), + fixValue: Type.Optional(Type.Unknown()), + fixLabel: Type.Optional(Type.String()), + docsPath: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index ea0323587a98..8ab396e2f477 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -5,6 +5,9 @@ import type { HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { stopGmailWatcher } from "../hooks/gmail-watcher.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("gateway"); export function createGatewayCloseHandler(params: { bonjourStop: (() => Promise) | null; @@ -40,8 +43,8 @@ export function createGatewayCloseHandler(params: { if (params.bonjourStop) { try { await params.bonjourStop(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: bonjour stop failed: ${String(err)}`); } } if (params.tailscaleCleanup) { @@ -50,22 +53,24 @@ export function createGatewayCloseHandler(params: { if (params.canvasHost) { try { await params.canvasHost.close(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: canvas host close failed: ${String(err)}`); } } if (params.canvasHostServer) { try { await params.canvasHostServer.close(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: canvas host server close failed: ${String(err)}`); } } for (const plugin of listChannelPlugins()) { await params.stopChannel(plugin.id); } if (params.pluginServices) { - await params.pluginServices.stop().catch(() => {}); + await params.pluginServices.stop().catch((err) => { + log.warn(`cleanup: plugin services stop failed: ${String(err)}`); + }); } await stopGmailWatcher(); params.cron.stop(); @@ -84,29 +89,33 @@ export function createGatewayCloseHandler(params: { if (params.agentUnsub) { try { params.agentUnsub(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: agent unsub failed: ${String(err)}`); } } if (params.heartbeatUnsub) { try { params.heartbeatUnsub(); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: heartbeat unsub failed: ${String(err)}`); } } params.chatRunState.clear(); for (const c of params.clients) { try { c.socket.close(1012, "service restart"); - } catch { - /* ignore */ + } catch (err) { + log.warn(`cleanup: client socket close failed: ${String(err)}`); } } params.clients.clear(); - await params.configReloader.stop().catch(() => {}); + await params.configReloader.stop().catch((err) => { + log.warn(`cleanup: config reloader stop failed: ${String(err)}`); + }); if (params.browserControl) { - await params.browserControl.stop().catch(() => {}); + await params.browserControl.stop().catch((err) => { + log.warn(`cleanup: browser control stop failed: ${String(err)}`); + }); } await new Promise((resolve) => params.wss.close(() => resolve())); const servers = diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 05a534454ac0..82e10949f2c3 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -143,6 +143,7 @@ export const configHandlers: GatewayRequestHandlers = { id: entry.id, label: entry.meta.label, description: entry.meta.blurb, + docsPath: entry.meta.docsPath, configSchema: entry.configSchema?.schema, configUiHints: entry.configSchema?.uiHints, })), diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4dbab48ff143..397a13be68c7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -28,9 +28,32 @@ export type PluginLogger = { export type PluginConfigUiHint = { label?: string; help?: string; + group?: string; + order?: number; advanced?: boolean; sensitive?: boolean; placeholder?: string; + itemTemplate?: unknown; + docsPath?: string; + impacts?: Array<{ + relation?: "requires" | "conflicts" | "recommends" | "risk"; + targetPath?: string; + when?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + whenValue?: unknown; + targetWhen?: + | "truthy" + | "falsy" + | "defined" + | "notDefined" + | "equals" + | "notEquals" + | "includes"; + targetValue?: unknown; + message: string; + fixValue?: unknown; + fixLabel?: string; + docsPath?: string; + }>; }; export type PluginKind = "memory"; diff --git a/src/signal/client.ts b/src/signal/client.ts index 1551183f141c..3a7f716e7289 100644 --- a/src/signal/client.ts +++ b/src/signal/client.ts @@ -81,7 +81,12 @@ export async function signalRpcRequest( if (!text) { throw new Error(`Signal RPC empty response (status ${res.status})`); } - const parsed = JSON.parse(text) as SignalRpcResponse; + let parsed: SignalRpcResponse; + try { + parsed = JSON.parse(text) as SignalRpcResponse; + } catch { + throw new Error(`Signal RPC malformed JSON (status ${res.status}): ${text.slice(0, 200)}`); + } if (parsed.error) { const code = parsed.error.code ?? "unknown"; const msg = parsed.error.message ?? "Signal RPC error"; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index f0438eeec2fb..9bd97396ce58 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -91,6 +91,14 @@ gap: 6px; } +.stat-card-clickable { + transition: border-color var(--duration-fast) ease; +} + +.stat-card-clickable:hover { + border-color: var(--border-strong); +} + .note-title { font-weight: 600; letter-spacing: -0.01em; @@ -397,6 +405,20 @@ color: var(--accent); } +.btn.quiet { + background: transparent; + border-color: transparent; + color: var(--muted); + box-shadow: none; +} + +.btn.quiet:hover { + background: var(--bg-hover); + border-color: var(--border); + color: var(--text); + transform: none; +} + .btn.danger { border-color: transparent; background: var(--danger-subtle); @@ -407,6 +429,30 @@ background: rgba(239, 68, 68, 0.15); } +.section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.section-header__meta { + min-width: 0; +} + +.section-header__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.subtle-divider { + height: 1px; + background: var(--border); + margin: 12px 0; +} + .btn--sm { padding: 6px 10px; font-size: 12px; @@ -1042,6 +1088,16 @@ border-color: var(--danger-subtle); } +/* Severity row tinting — subtle background to distinguish warn/error rows */ +.log-row.log-row--warn { + background: color-mix(in srgb, var(--warn) 5%, transparent); +} + +.log-row.log-row--error, +.log-row.log-row--fatal { + background: color-mix(in srgb, var(--danger) 6%, transparent); +} + .log-subsystem { color: var(--muted); font-family: var(--mono); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index ec4003a12444..58787ba69641 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -615,6 +615,55 @@ gap: 22px; } +.cfg-group { + display: grid; + gap: 14px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-elevated) 85%, transparent); +} + +.cfg-group__title { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted, var(--muted)); +} + +.cfg-group__body { + display: grid; + gap: 14px; +} + +.cfg-group--advanced { + padding: 0; + overflow: hidden; +} + +.cfg-group--advanced > summary { + list-style: none; + cursor: pointer; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + background: var(--bg-accent); +} + +.cfg-group--advanced > summary::-webkit-details-marker { + display: none; +} + +.cfg-group--advanced .cfg-group__body { + padding: 14px; +} + .cfg-field { display: grid; gap: 8px; @@ -639,6 +688,70 @@ line-height: 1.45; } +.cfg-field__assist { + display: grid; + gap: 8px; +} + +.cfg-field__docs { + display: inline-flex; + width: fit-content; + font-size: 12px; + color: var(--accent); + text-decoration: none; +} + +.cfg-field__docs:hover { + text-decoration: underline; +} + +.cfg-impact-list { + display: grid; + gap: 8px; +} + +.cfg-impact { + display: grid; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--bg-accent); + font-size: 12px; + line-height: 1.45; +} + +.cfg-impact--info { + border-color: rgba(59, 130, 246, 0.25); +} + +.cfg-impact--warn { + border-color: rgba(245, 158, 11, 0.3); + background: color-mix(in srgb, rgba(245, 158, 11, 0.08) 60%, var(--bg-accent)); +} + +.cfg-impact--danger { + border-color: rgba(239, 68, 68, 0.3); + background: color-mix(in srgb, rgba(239, 68, 68, 0.08) 60%, var(--bg-accent)); +} + +.cfg-impact__actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cfg-impact__link { + font-size: 12px; + color: var(--accent); + text-decoration: none; +} + +.cfg-impact__link:hover { + text-decoration: underline; +} + .cfg-field__error { font-size: 12px; color: var(--danger); @@ -985,7 +1098,7 @@ box-shadow: var(--focus-ring); } -/* Object (collapsible) */ +/* Object (collapsible) — top level */ .cfg-object { border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -993,10 +1106,25 @@ overflow: hidden; } +/* Nested objects: remove stacked boxes and rely on spacing */ +.cfg-object .cfg-object { + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + background: transparent; +} + +.cfg-object .cfg-object .cfg-object { + border-color: color-mix(in srgb, var(--border) 70%, transparent); +} + :root[data-theme="light"] .cfg-object { background: white; } +:root[data-theme="light"] .cfg-object .cfg-object { + background: transparent; +} + .cfg-object__header { display: flex; align-items: center; @@ -1007,6 +1135,11 @@ transition: background var(--duration-fast) ease; } +/* Nested object headers: less padding */ +.cfg-object .cfg-object .cfg-object__header { + padding: 10px 14px; +} + .cfg-object__header:hover { background: var(--bg-hover); } @@ -1021,6 +1154,13 @@ color: var(--text); } +/* Nested object titles: smaller, less prominent */ +.cfg-object .cfg-object .cfg-object__title { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + .cfg-object__chevron { width: 18px; height: 18px; @@ -1044,12 +1184,24 @@ border-bottom: 1px solid var(--border); } +/* Nested help: no bottom border */ +.cfg-object .cfg-object .cfg-object__help { + border-bottom: none; + padding: 0 14px 10px; +} + .cfg-object__content { padding: 18px; display: grid; gap: 18px; } +/* Nested content: tighter spacing */ +.cfg-object .cfg-object .cfg-object__content { + padding: 10px 14px; + gap: 14px; +} + /* Array */ .cfg-array { border: 1px solid var(--border); @@ -1123,6 +1275,18 @@ height: 100%; } +.cfg-array > .cfg-field__assist { + padding: 10px 18px; + border-bottom: 1px solid var(--border); +} + +.cfg-array__meta { + padding: 10px 18px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + .cfg-array__help { padding: 12px 18px; font-size: 12px; @@ -1259,6 +1423,18 @@ height: 100%; } +.cfg-map > .cfg-field__assist { + padding: 10px 18px; + border-bottom: 1px solid var(--border); +} + +.cfg-map__meta { + padding: 10px 18px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + .cfg-map__empty { padding: 28px 18px; text-align: center; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index d416bde447ef..b948b05301e5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -236,6 +236,7 @@ export function renderApp(state: AppViewState) { }, onConnect: () => state.connect(), onRefresh: () => state.loadOverview(), + onNavigate: (tab) => state.setTab(tab), }) : nothing } diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 292c5780b35a..6e165ca3e7d7 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -158,6 +158,121 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["slack"], {}); }); + it("shows contextual map-key guidance for discord guild maps", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + guilds: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { channels: { discord: { guilds: { "123456789012345678": "on" } } } }, + onPatch, + }), + container, + ); + + const addButton = Array.from( + container.querySelectorAll(".cfg-map__add"), + ).find((button) => button.textContent?.includes("Add Guild")); + expect(addButton).toBeDefined(); + expect(container.textContent).toContain("Use Discord guild IDs"); + expect( + container + .querySelector(".cfg-map__item-key input") + ?.getAttribute("placeholder"), + ).toBe("123456789012345678"); + }); + + it("uses singular add labels for arrays with named hints", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + owners: { + type: "array", + items: { type: "string" }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + owners: { label: "Owners" }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { owners: [] }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Add Owner"); + expect(container.textContent).toContain("Each owner expects text."); + }); + + it("applies fallback hints when backend uiHints are missing", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + allowBots: { type: "boolean" }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { channels: { discord: { allowBots: true } } }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Allow replies to bot-authored messages"); + expect(container.textContent).toContain("bot-to-bot reply loops"); + const docsLink = container.querySelector("a.cfg-field__docs"); + expect(docsLink?.getAttribute("href")).toBe("https://docs.openclaw.ai/gateway/configuration"); + }); + it("supports wildcard uiHints for map entries", () => { const onPatch = vi.fn(); const container = document.createElement("div"); @@ -197,6 +312,165 @@ describe("config form renderer", () => { expect(container.textContent).toContain("Plugin Enabled"); }); + it("groups fields by uiHints and collapses advanced groups", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + server: { + type: "object", + properties: { + host: { type: "string" }, + port: { type: "number" }, + token: { type: "string" }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + "server.host": { group: "Connection" }, + "server.port": { group: "Connection" }, + "server.token": { group: "Security", advanced: true }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { server: { host: "localhost", port: 8080, token: "secret" } }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Connection"); + const advancedGroup = Array.from( + container.querySelectorAll("details.cfg-group--advanced"), + ).find((el) => el.textContent?.includes("Security")); + expect(advancedGroup).toBeDefined(); + expect((advancedGroup as HTMLDetailsElement).open).toBe(false); + }); + + it("collapses deep nested objects by default", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + gateway: { + type: "object", + properties: { + nested: { + type: "object", + properties: { + actions: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { gateway: { nested: { actions: { enabled: true } } } }, + onPatch, + }), + container, + ); + + const deepObject = Array.from(container.querySelectorAll("details.cfg-object")).find((el) => { + const title = el.querySelector(".cfg-object__title")?.textContent?.trim(); + return title === "Actions"; + }); + expect(deepObject).toBeDefined(); + expect((deepObject as HTMLDetailsElement).open).toBe(false); + }); + + it("surfaces impact warnings and applies quick fixes", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + actions: { + type: "object", + properties: { + roles: { type: "boolean" }, + permissions: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + "channels.discord.actions.roles": { + docsPath: "/channels/discord", + impacts: [ + { + relation: "requires", + when: "truthy", + targetPath: "channels.discord.actions.permissions", + targetWhen: "truthy", + message: "Role management depends on permissions actions being enabled.", + fixValue: true, + fixLabel: "Enable permissions", + }, + ], + }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { + channels: { + discord: { + actions: { + roles: true, + permissions: false, + }, + }, + }, + }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain( + "Role management depends on permissions actions being enabled.", + ); + const docsLink = container.querySelector("a.cfg-field__docs"); + expect(docsLink?.getAttribute("href")).toBe("https://docs.openclaw.ai/channels/discord"); + + const fixButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Enable permissions", + ); + expect(fixButton).toBeDefined(); + fixButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["channels", "discord", "actions", "permissions"], true); + }); + it("flags unsupported unions", () => { const schema = { type: "object", diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index 7c99380a8612..9cd5e8e7ee26 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -21,13 +21,27 @@ export function formatNextRun(ms?: number | null) { return `${formatMs(ms)} (${formatAgo(ms)})`; } +function formatTokenCount(n: number): string { + if (n >= 1_000_000) { + const val = n / 1_000_000; + return val % 1 === 0 ? `${val}M` : `${val.toFixed(1)}M`; + } + if (n >= 1_000) { + const val = n / 1_000; + return val % 1 === 0 ? `${val}K` : `${val.toFixed(1)}K`; + } + return String(n); +} + export function formatSessionTokens(row: GatewaySessionRow) { if (row.totalTokens == null) { return "n/a"; } const total = row.totalTokens ?? 0; const ctx = row.contextTokens ?? 0; - return ctx ? `${total} / ${ctx}` : String(total); + return ctx + ? `${formatTokenCount(total)} / ${formatTokenCount(ctx)} tokens` + : `${formatTokenCount(total)} tokens`; } export function formatEventPayload(payload: unknown): string { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 1c85b87319b6..c031a6619204 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -15,6 +15,9 @@ export type ChannelUiMetaEntry = { label: string; detailLabel: string; systemImage?: string; + description?: string; + docsPath?: string; + docsLabel?: string; }; export const CRON_CHANNEL_LAST = "last"; @@ -282,6 +285,7 @@ export type ConfigSnapshot = { }; export type ConfigUiHint = { + docsPath?: string; label?: string; help?: string; group?: string; @@ -290,6 +294,20 @@ export type ConfigUiHint = { sensitive?: boolean; placeholder?: string; itemTemplate?: unknown; + impacts?: ConfigUiHintImpact[]; +}; + +export type ConfigUiHintImpact = { + relation?: "requires" | "conflicts" | "recommends" | "risk"; + targetPath?: string; + when?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + whenValue?: unknown; + targetWhen?: "truthy" | "falsy" | "defined" | "notDefined" | "equals" | "notEquals" | "includes"; + targetValue?: unknown; + message: string; + fixValue?: unknown; + fixLabel?: string; + docsPath?: string; }; export type ConfigUiHints = Record; diff --git a/ui/src/ui/views/channels.config.test.ts b/ui/src/ui/views/channels.config.test.ts new file mode 100644 index 000000000000..351899438d48 --- /dev/null +++ b/ui/src/ui/views/channels.config.test.ts @@ -0,0 +1,75 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { ChannelsProps } from "./channels.types.ts"; +import { renderChannelConfigSection } from "./channels.config.ts"; + +function makeProps(overrides: Record = {}): ChannelsProps { + return { + configSaving: false, + configSchemaLoading: false, + configFormDirty: false, + configForm: { + channels: { + discord: { + enabled: true, + }, + }, + }, + configSchema: { + type: "object", + properties: { + channels: { + type: "object", + properties: { + discord: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + configUiHints: {}, + onConfigPatch: vi.fn(), + onConfigSave: vi.fn(), + onConfigReload: vi.fn(), + ...overrides, + } as unknown as ChannelsProps; +} + +describe("channel config section", () => { + it("keeps channel settings collapsed by default", () => { + const container = document.createElement("div"); + render(renderChannelConfigSection({ channelId: "discord", props: makeProps() }), container); + + const details = container.querySelector("details.cfg-group--advanced"); + expect(details).not.toBeNull(); + expect(details?.open).toBe(false); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Save", + ); + const reloadButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Reload", + ); + expect(saveButton?.className).toContain("primary"); + expect(reloadButton?.className).toContain("quiet"); + }); + + it("expands channel settings when form is dirty", () => { + const container = document.createElement("div"); + render( + renderChannelConfigSection({ + channelId: "discord", + props: makeProps({ configFormDirty: true }), + }), + container, + ); + + const details = container.querySelector("details.cfg-group--advanced"); + expect(details).not.toBeNull(); + expect(details?.open).toBe(true); + }); +}); diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts index b94b750134d5..17c03fc3cf81 100644 --- a/ui/src/ui/views/channels.config.ts +++ b/ui/src/ui/views/channels.config.ts @@ -124,6 +124,7 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) { ${renderNode({ schema: node, value, + rootValue: configValue, path: ["channels", props.channelId], hints: props.uiHints, unsupported: new Set(analysis.unsupportedPaths), @@ -139,38 +140,44 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) { export function renderChannelConfigSection(params: { channelId: string; props: ChannelsProps }) { const { channelId, props } = params; const disabled = props.configSaving || props.configSchemaLoading; + const openEditor = props.configFormDirty || props.configSchemaLoading; return html`
- ${ - props.configSchemaLoading - ? html` -
Loading config schema…
- ` - : renderChannelConfigForm({ - channelId, - configValue: props.configForm, - schema: props.configSchema, - uiHints: props.configUiHints, - disabled, - onPatch: props.onConfigPatch, - }) - } -
- - -
+
+ Channel settings +
+ ${ + props.configSchemaLoading + ? html` +
Loading config schema…
+ ` + : renderChannelConfigForm({ + channelId, + configValue: props.configForm, + schema: props.configSchema, + uiHints: props.configUiHints, + disabled, + onPatch: props.onConfigPatch, + }) + } +
+ + +
+
+
`; } diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts index 69df08288b14..73b9d9e7eedb 100644 --- a/ui/src/ui/views/channels.discord.ts +++ b/ui/src/ui/views/channels.discord.ts @@ -3,6 +3,7 @@ import type { DiscordStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderDiscordCard(params: { props: ChannelsProps; @@ -13,8 +14,15 @@ export function renderDiscordCard(params: { return html`
-
Discord
-
Bot status and channel configuration.
+ ${renderChannelHeader({ + channelId: "discord", + props, + fallbackTitle: "Discord", + fallbackSub: "Bot status and channel configuration.", + actions: html``, + })} ${accountCountLabel}
@@ -46,20 +54,21 @@ export function renderDiscordCard(params: { ${ discord?.probe - ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} · - ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} -
` + ? html` +
+ Latest probe +
+
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
+
+
+ ` : nothing } ${renderChannelConfigSection({ channelId: "discord", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts index 506a902816af..180d5fd0c802 100644 --- a/ui/src/ui/views/channels.googlechat.ts +++ b/ui/src/ui/views/channels.googlechat.ts @@ -3,6 +3,7 @@ import type { GoogleChatStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderGoogleChatCard(params: { props: ChannelsProps; @@ -13,8 +14,15 @@ export function renderGoogleChatCard(params: { return html`
-
Google Chat
-
Chat API webhook status and channel configuration.
+ ${renderChannelHeader({ + channelId: "googlechat", + props, + fallbackTitle: "Google Chat", + fallbackSub: "Chat API webhook status and channel configuration.", + actions: html``, + })} ${accountCountLabel}
@@ -68,12 +76,6 @@ export function renderGoogleChatCard(params: { } ${renderChannelConfigSection({ channelId: "googlechat", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts index c54cd6c5e4f2..95f9a60517ce 100644 --- a/ui/src/ui/views/channels.imessage.ts +++ b/ui/src/ui/views/channels.imessage.ts @@ -3,6 +3,7 @@ import type { IMessageStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderIMessageCard(params: { props: ChannelsProps; @@ -13,8 +14,15 @@ export function renderIMessageCard(params: { return html`
-
iMessage
-
macOS bridge status and channel configuration.
+ ${renderChannelHeader({ + channelId: "imessage", + props, + fallbackTitle: "iMessage", + fallbackSub: "macOS bridge status and channel configuration.", + actions: html``, + })} ${accountCountLabel}
@@ -54,12 +62,6 @@ export function renderIMessageCard(params: { } ${renderChannelConfigSection({ channelId: "imessage", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts index 8bd8b7c0443a..a670b366d6f1 100644 --- a/ui/src/ui/views/channels.nostr.ts +++ b/ui/src/ui/views/channels.nostr.ts @@ -8,6 +8,7 @@ import { type NostrProfileFormState, type NostrProfileFormCallbacks, } from "./channels.nostr-profile-form.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; /** * Truncate a pubkey for display (shows first and last 8 chars) @@ -184,8 +185,15 @@ export function renderNostrCard(params: { return html`
-
Nostr
-
Decentralized DMs via Nostr relays (NIP-04).
+ ${renderChannelHeader({ + channelId: "nostr", + props, + fallbackTitle: "Nostr", + fallbackSub: "Decentralized DMs via Nostr relays (NIP-04).", + actions: html``, + })} ${accountCountLabel} ${ @@ -228,10 +236,6 @@ export function renderNostrCard(params: { ${renderProfileSection()} ${renderChannelConfigSection({ channelId: "nostr", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.shared.test.ts b/ui/src/ui/views/channels.shared.test.ts new file mode 100644 index 000000000000..9f7e51dd5f50 --- /dev/null +++ b/ui/src/ui/views/channels.shared.test.ts @@ -0,0 +1,50 @@ +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import type { ChannelsProps } from "./channels.types.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; + +function makeProps(overrides: Record = {}): ChannelsProps { + return { + snapshot: null, + ...overrides, + } as unknown as ChannelsProps; +} + +describe("channel header", () => { + it("renders docs guide links from channel metadata", () => { + const container = document.createElement("div"); + const props = makeProps({ + snapshot: { + ts: Date.now(), + channelOrder: ["discord"], + channelLabels: { discord: "Discord" }, + channels: {}, + channelAccounts: {}, + channelDefaultAccountId: {}, + channelMeta: [ + { + id: "discord", + label: "Discord", + detailLabel: "Discord Bot", + description: "Bot status and channel configuration.", + docsPath: "/channels/discord", + }, + ], + }, + }); + + render( + renderChannelHeader({ + channelId: "discord", + props, + fallbackTitle: "Discord", + fallbackSub: "Fallback summary", + }), + container, + ); + + expect(container.textContent).toContain("Bot status and channel configuration."); + const guideLink = container.querySelector("a.btn.quiet"); + expect(guideLink?.getAttribute("href")).toBe("https://docs.openclaw.ai/channels/discord"); + }); +}); diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/ui/views/channels.shared.ts index 343c60b11fd6..8e844523f428 100644 --- a/ui/src/ui/views/channels.shared.ts +++ b/ui/src/ui/views/channels.shared.ts @@ -1,5 +1,9 @@ import { html, nothing } from "lit"; -import type { ChannelAccountSnapshot } from "../types.ts"; +import type { + ChannelAccountSnapshot, + ChannelUiMetaEntry, + ChannelsStatusSnapshot, +} from "../types.ts"; import type { ChannelKey, ChannelsProps } from "./channels.types.ts"; export function formatDuration(ms?: number | null) { @@ -52,3 +56,93 @@ export function renderChannelAccountCount( } return html``; } + +function resolveChannelMetaMap( + snapshot: ChannelsStatusSnapshot | null, +): Record { + if (!snapshot?.channelMeta?.length) { + return {}; + } + return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); +} + +export function resolveChannelMeta( + snapshot: ChannelsStatusSnapshot | null, + key: string, +): ChannelUiMetaEntry | null { + return resolveChannelMetaMap(snapshot)[key] ?? null; +} + +export function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { + const meta = resolveChannelMeta(snapshot, key); + return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; +} + +export function resolveChannelDescription( + snapshot: ChannelsStatusSnapshot | null, + key: string, + fallback: string, +): string { + const meta = resolveChannelMeta(snapshot, key); + return meta?.description?.trim() || fallback; +} + +function toDocsUrl(path?: string): string | null { + const raw = path?.trim(); + if (!raw) { + return null; + } + if (raw.startsWith("http://") || raw.startsWith("https://")) { + return raw; + } + if (!raw.startsWith("/")) { + return `https://docs.openclaw.ai/${raw}`; + } + return `https://docs.openclaw.ai${raw}`; +} + +export function resolveChannelDocsUrl( + snapshot: ChannelsStatusSnapshot | null, + key: string, +): string | null { + const meta = resolveChannelMeta(snapshot, key); + return toDocsUrl(meta?.docsPath); +} + +export function renderChannelHeader(params: { + channelId: string; + props: ChannelsProps; + fallbackTitle: string; + fallbackSub: string; + actions?: unknown; +}) { + const title = + resolveChannelLabel(params.props.snapshot, params.channelId) ?? params.fallbackTitle; + const description = resolveChannelDescription( + params.props.snapshot, + params.channelId, + params.fallbackSub, + ); + const docsUrl = resolveChannelDocsUrl(params.props.snapshot, params.channelId); + + return html` +
+
+
${title}
+
${description}
+
+
+ ${ + docsUrl + ? html` + + Guide + + ` + : nothing + } + ${params.actions ?? nothing} +
+
+ `; +} diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts index a2f2327c28c0..cb3df08be24f 100644 --- a/ui/src/ui/views/channels.signal.ts +++ b/ui/src/ui/views/channels.signal.ts @@ -3,6 +3,7 @@ import type { SignalStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderSignalCard(params: { props: ChannelsProps; @@ -13,8 +14,15 @@ export function renderSignalCard(params: { return html`
-
Signal
-
signal-cli status and channel configuration.
+ ${renderChannelHeader({ + channelId: "signal", + props, + fallbackTitle: "Signal", + fallbackSub: "signal-cli status and channel configuration.", + actions: html``, + })} ${accountCountLabel}
@@ -58,12 +66,6 @@ export function renderSignalCard(params: { } ${renderChannelConfigSection({ channelId: "signal", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts index 91180de313ea..06f4afa4c2be 100644 --- a/ui/src/ui/views/channels.slack.ts +++ b/ui/src/ui/views/channels.slack.ts @@ -3,6 +3,7 @@ import type { SlackStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderSlackCard(params: { props: ChannelsProps; @@ -13,8 +14,15 @@ export function renderSlackCard(params: { return html`
-
Slack
-
Socket mode status and channel configuration.
+ ${renderChannelHeader({ + channelId: "slack", + props, + fallbackTitle: "Slack", + fallbackSub: "Socket mode status and channel configuration.", + actions: html``, + })} ${accountCountLabel}
@@ -54,12 +62,6 @@ export function renderSlackCard(params: { } ${renderChannelConfigSection({ channelId: "slack", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts index 270ae078273c..7634bc790cf8 100644 --- a/ui/src/ui/views/channels.telegram.ts +++ b/ui/src/ui/views/channels.telegram.ts @@ -3,6 +3,7 @@ import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; +import { renderChannelHeader } from "./channels.shared.ts"; export function renderTelegramCard(params: { props: ChannelsProps; @@ -54,8 +55,15 @@ export function renderTelegramCard(params: { return html`
-
Telegram
-
Bot status and channel configuration.
+ ${renderChannelHeader({ + channelId: "telegram", + props, + fallbackTitle: "Telegram", + fallbackSub: "Bot status and channel configuration.", + actions: html``, + })} ${accountCountLabel} ${ @@ -109,12 +117,6 @@ export function renderTelegramCard(params: { } ${renderChannelConfigSection({ channelId: "telegram", props })} - -
- -
`; } diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index c1983fef076c..155a8f8eacc9 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -1,7 +1,6 @@ import { html, nothing } from "lit"; import type { ChannelAccountSnapshot, - ChannelUiMetaEntry, ChannelsStatusSnapshot, DiscordStatus, GoogleChatStatus, @@ -20,7 +19,12 @@ import { renderDiscordCard } from "./channels.discord.ts"; import { renderGoogleChatCard } from "./channels.googlechat.ts"; import { renderIMessageCard } from "./channels.imessage.ts"; import { renderNostrCard } from "./channels.nostr.ts"; -import { channelEnabled, renderChannelAccountCount } from "./channels.shared.ts"; +import { + channelEnabled, + renderChannelAccountCount, + renderChannelHeader, + resolveChannelLabel, +} from "./channels.shared.ts"; import { renderSignalCard } from "./channels.signal.ts"; import { renderSlackCard } from "./channels.slack.ts"; import { renderTelegramCard } from "./channels.telegram.ts"; @@ -68,13 +72,17 @@ export function renderChannels(props: ChannelsProps) {
-
-
+
+
Channel health
Channel status snapshots from the gateway.
${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
+
+ If setup is unclear or replies are missing, run openclaw doctor and + openclaw channels status --probe to get targeted fixes. +
${ props.lastError ? html`
@@ -82,9 +90,12 @@ export function renderChannels(props: ChannelsProps) {
` : nothing } -
+      
+ Show raw data +
 ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
-      
+
+
`; } @@ -193,8 +204,12 @@ function renderGenericChannelCard( return html`
-
${label}
-
Channel status and configuration.
+ ${renderChannelHeader({ + channelId: key, + props, + fallbackTitle: label, + fallbackSub: "Channel status and configuration.", + })} ${accountCountLabel} ${ @@ -235,20 +250,6 @@ function renderGenericChannelCard( `; } -function resolveChannelMetaMap( - snapshot: ChannelsStatusSnapshot | null, -): Record { - if (!snapshot?.channelMeta?.length) { - return {}; - } - return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); -} - -function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { - const meta = resolveChannelMetaMap(snapshot)[key]; - return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; -} - const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes function hasRecentActivity(account: ChannelAccountSnapshot): boolean { diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts index 149861fca946..100812af0e0e 100644 --- a/ui/src/ui/views/channels.whatsapp.ts +++ b/ui/src/ui/views/channels.whatsapp.ts @@ -3,7 +3,7 @@ import type { WhatsAppStatus } from "../types.ts"; import type { ChannelsProps } from "./channels.types.ts"; import { formatAgo } from "../format.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { formatDuration } from "./channels.shared.ts"; +import { formatDuration, renderChannelHeader } from "./channels.shared.ts"; export function renderWhatsAppCard(params: { props: ChannelsProps; @@ -14,8 +14,15 @@ export function renderWhatsAppCard(params: { return html`
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
+ ${renderChannelHeader({ + channelId: "whatsapp", + props, + fallbackTitle: "WhatsApp", + fallbackSub: "Link WhatsApp Web and monitor connection health.", + actions: html``, + })} ${accountCountLabel}
@@ -108,9 +115,6 @@ export function renderWhatsAppCard(params: { > Logout -
${renderChannelConfigSection({ channelId: "whatsapp", props })} diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index c37ae6d1258a..06b507326dc1 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -6,7 +6,9 @@ import { humanize, isSensitivePath, pathKey, + resolveConfigImpacts, schemaType, + toDocsUrl, type JsonSchema, } from "./config-form.shared.ts"; @@ -95,9 +97,218 @@ const icons = { `, }; +type MapFieldGuidance = { + addLabel: string; + keyPlaceholder: string; + keyHelp?: string; +}; + +function singularizeLabel(label: string): string { + const trimmed = label.trim(); + if (!trimmed) { + return "item"; + } + if (trimmed.endsWith("ies") && trimmed.length > 3) { + return `${trimmed.slice(0, -3)}y`; + } + if (trimmed.endsWith("s") && trimmed.length > 1) { + return trimmed.slice(0, -1); + } + return trimmed; +} + +function inferArrayItemTypeLabel(schema: JsonSchema): string { + const type = schemaType(schema); + if (type === "string") { + return "text"; + } + if (type === "number" || type === "integer") { + return "number"; + } + if (type === "boolean") { + return "true/false"; + } + if (type === "object") { + return "object"; + } + if (type === "array") { + return "list"; + } + if (schema.enum?.length) { + return "one of the listed options"; + } + return "value"; +} + +function inferMapFieldGuidance( + path: Array, + fallbackLabel: string, +): MapFieldGuidance { + const segments = path.filter((segment): segment is string => typeof segment === "string"); + const leaf = segments[segments.length - 1]?.toLowerCase() ?? ""; + const secondLast = segments[segments.length - 2]?.toLowerCase() ?? ""; + const channelId = segments[1]?.toLowerCase(); + + if (leaf === "accounts") { + return { + addLabel: "Add Account", + keyPlaceholder: "main", + keyHelp: "Use a stable account ID key (for example: main, work, or backup).", + }; + } + if (leaf === "guilds") { + return { + addLabel: "Add Guild", + keyPlaceholder: "123456789012345678", + keyHelp: "Use Discord guild IDs (or configured slugs).", + }; + } + if (leaf === "channels" && (channelId === "discord" || secondLast === "guilds")) { + return { + addLabel: "Add Channel", + keyPlaceholder: "123456789012345678", + keyHelp: "Use Discord channel IDs.", + }; + } + if (leaf === "channels" && channelId === "slack") { + return { + addLabel: "Add Channel", + keyPlaceholder: "C1234567890", + keyHelp: "Use Slack channel IDs (or canonical channel keys from your config).", + }; + } + if (leaf === "groups" && channelId === "googlechat") { + return { + addLabel: "Add Space", + keyPlaceholder: "spaces/AAAA1234", + keyHelp: "Use Google Chat space IDs.", + }; + } + if (leaf === "groups") { + return { + addLabel: "Add Group", + keyPlaceholder: "group-id", + keyHelp: "Use a group/chat ID key recognized by this channel.", + }; + } + if (leaf === "dms") { + return { + addLabel: "Add DM", + keyPlaceholder: "user-id", + keyHelp: "Use the sender/user ID for per-DM overrides.", + }; + } + if (leaf === "entries") { + return { + addLabel: "Add Entry", + keyPlaceholder: "plugin-id", + keyHelp: "Use a unique entry key (for example a plugin or provider ID).", + }; + } + const singular = singularizeLabel(fallbackLabel); + return { + addLabel: `Add ${humanize(singular)}`, + keyPlaceholder: "custom-key", + }; +} + +function impactToneClass(relation: "requires" | "conflicts" | "recommends" | "risk"): string { + if (relation === "requires" || relation === "conflicts") { + return "danger"; + } + if (relation === "risk") { + return "warn"; + } + return "info"; +} + +function renderFieldAssist(params: { + path: Array; + hints: ConfigUiHints; + help?: string; + rootValue: unknown; + disabled: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult | typeof nothing { + const { path, hints, help, rootValue, disabled, onPatch } = params; + const hint = hintForPath(path, hints); + const docsUrl = toDocsUrl(hint?.docsPath); + const impacts = resolveConfigImpacts({ path, hints, rootValue }); + if (!help && !docsUrl && impacts.length === 0) { + return nothing; + } + return html` +
+ ${help ? html`
${help}
` : nothing} + ${ + docsUrl + ? html` + + Docs + + ` + : nothing + } + ${ + impacts.length > 0 + ? html` +
+ ${impacts.map((impact) => { + const docsHref = toDocsUrl(impact.docsPath); + return html` +
+ ${impact.message} +
+ ${ + docsHref + ? html` + + Guide + + ` + : nothing + } + ${ + impact.targetPath && impact.fixValue !== undefined + ? html` + + ` + : nothing + } +
+
+ `; + })} +
+ ` + : nothing + } +
+ `; +} + export function renderNode(params: { schema: JsonSchema; value: unknown; + rootValue: unknown; path: Array; hints: ConfigUiHints; unsupported: Set; @@ -105,7 +316,7 @@ export function renderNode(params: { showLabel?: boolean; onPatch: (path: Array, value: unknown) => void; }): TemplateResult | typeof nothing { - const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const { schema, value, rootValue, path, hints, unsupported, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const type = schemaType(schema); const hint = hintForPath(path, hints); @@ -147,10 +358,18 @@ export function renderNode(params: { if (allLiterals && literals.length > 0 && literals.length <= 5) { // Use segmented control for small sets const resolvedValue = value ?? schema.default; + const assist = renderFieldAssist({ + path, + hints, + help, + rootValue, + disabled, + onPatch, + }); return html`
${showLabel ? html`` : nothing} - ${help ? html`
${help}
` : nothing} + ${assist}
${literals.map( (lit) => html` @@ -212,10 +431,18 @@ export function renderNode(params: { const options = schema.enum; if (options.length <= 5) { const resolvedValue = value ?? schema.default; + const assist = renderFieldAssist({ + path, + hints, + help, + rootValue, + disabled, + onPatch, + }); return html`
${showLabel ? html`` : nothing} - ${help ? html`
${help}
` : nothing} + ${assist}
${options.map( (opt) => html` @@ -254,22 +481,32 @@ export function renderNode(params: { : typeof schema.default === "boolean" ? schema.default : false; + const assist = renderFieldAssist({ + path, + hints, + help, + rootValue, + disabled, + onPatch, + }); return html` - +
+ + ${assist} +
`; } @@ -295,6 +532,7 @@ export function renderNode(params: { function renderTextInput(params: { schema: JsonSchema; value: unknown; + rootValue: unknown; path: Array; hints: ConfigUiHints; disabled: boolean; @@ -302,11 +540,12 @@ function renderTextInput(params: { inputType: "text" | "number"; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, disabled, onPatch, inputType } = params; + const { schema, value, rootValue, path, hints, disabled, onPatch, inputType } = params; const showLabel = params.showLabel ?? true; const hint = hintForPath(path, hints); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const help = hint?.help ?? schema.description; + const assist = renderFieldAssist({ path, hints, help, rootValue, disabled, onPatch }); const isSensitive = hint?.sensitive ?? isSensitivePath(path); const placeholder = hint?.placeholder ?? @@ -321,7 +560,7 @@ function renderTextInput(params: { return html`
${showLabel ? html`` : nothing} - ${help ? html`
${help}
` : nothing} + ${assist}
; hints: ConfigUiHints; disabled: boolean; showLabel?: boolean; onPatch: (path: Array, value: unknown) => void; }): TemplateResult { - const { schema, value, path, hints, disabled, onPatch } = params; + const { schema, value, rootValue, path, hints, disabled, onPatch } = params; const showLabel = params.showLabel ?? true; const hint = hintForPath(path, hints); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const help = hint?.help ?? schema.description; + const assist = renderFieldAssist({ path, hints, help, rootValue, disabled, onPatch }); const displayValue = value ?? schema.default ?? ""; const numValue = typeof displayValue === "number" ? displayValue : 0; return html`
${showLabel ? html`` : nothing} - ${help ? html`
${help}
` : nothing} + ${assist}
${renderScheduleFields(props)}
- - - +
+ +
+ ${props.form.sessionTarget === "isolated" ? "Each run starts a fresh session — no shared context." : "Runs continue in the agent's main session with full context."} +
+
+
+ +
+ ${props.form.wakeMode === "next-heartbeat" ? "Queued until the next heartbeat cycle." : "Runs immediately when the schedule fires."} +
+
+
+ +
+ ${props.form.payloadKind === "agentTurn" ? "Sends a message to the agent and waits for a reply." : "Fires a system event the agent can react to via hooks."} +
+