added admin interface
This commit is contained in:
parent
3335ceb9b7
commit
84815818f7
13 changed files with 2373 additions and 1169 deletions
|
|
@ -29,6 +29,9 @@ COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
|||
# Copy built Web UI assets
|
||||
COPY --from=builder /src/ui/dist /usr/local/share/picoclaw/ui/dist
|
||||
|
||||
# Copy config schema for /admin/schema
|
||||
COPY --from=builder /src/config/config.schema.json /usr/local/share/picoclaw/config/config.schema.json
|
||||
|
||||
# Create picoclaw home directory
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -702,6 +702,8 @@ picoclaw agent -m "Hello"
|
|||
<details>
|
||||
<summary><b>Full config example</b></summary>
|
||||
|
||||
Schema: `config/config.schema.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
|
|
@ -796,6 +798,32 @@ If you set `gateway.token`, open:
|
|||
|
||||
The listen address is controlled by `gateway.bind` (`local`, `tailnet`, `all`).
|
||||
|
||||
### Admin Web UI (Config Editor)
|
||||
|
||||
The gateway also serves an admin web UI at:
|
||||
|
||||
`http://<gateway-host>:18790/admin`
|
||||
|
||||
It provides two editing modes:
|
||||
|
||||
- **Form editor**: a generic UI generated from `config/config.schema.json` (served by the gateway).
|
||||
- **Raw JSON editor**: edit the full config as JSON.
|
||||
|
||||
**Authentication**
|
||||
|
||||
The admin UI uses the same bearer token as the Admin API.
|
||||
Set `gateway.admin_token` (or `PICOCLAW_GATEWAY_ADMIN_TOKEN`) and enter it in the UI.
|
||||
Requests are sent with:
|
||||
|
||||
`Authorization: Bearer <admin_token>`
|
||||
|
||||
If `gateway.admin_token` is empty, `/admin/*` will always return `401 Unauthorized`.
|
||||
|
||||
**Writable config required**
|
||||
|
||||
Saving changes writes to the normal config path (e.g. `~/.picoclaw/config.json`, or `/root/.picoclaw/config.json` in Docker).
|
||||
If the config file is mounted read-only, saving will fail with **"config is not writable"**.
|
||||
|
||||
### Admin API (Config Update + Graceful Restart)
|
||||
|
||||
The gateway also exposes an **API-only** management interface for remote administration.
|
||||
|
|
@ -803,6 +831,8 @@ The gateway also exposes an **API-only** management interface for remote adminis
|
|||
It supports:
|
||||
|
||||
- **Replace config**: `PUT /admin/config`
|
||||
- **Read config**: `GET /admin/config`
|
||||
- **Read config schema**: `GET /admin/schema`
|
||||
- **Graceful drain + exit(0)** (so Docker/systemd can restart it): `POST /admin/drain-exit`
|
||||
|
||||
**Authentication**
|
||||
|
|
|
|||
|
|
@ -735,6 +735,9 @@ func gatewayCmd() {
|
|||
wc.SetConfigUpdate(func(raw []byte) error {
|
||||
return saveConfigRawAtomic(configPath, raw)
|
||||
})
|
||||
wc.SetConfigRead(func() ([]byte, error) {
|
||||
return os.ReadFile(configPath)
|
||||
})
|
||||
wc.SetDrainExit(func(timeout time.Duration) error {
|
||||
shutdown(timeout)
|
||||
os.Exit(0)
|
||||
|
|
|
|||
444
config/config.schema.json
Normal file
444
config/config.schema.json
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/sipeed/picoclaw/blob/main/config/config.schema.json",
|
||||
"title": "PicoClaw Config",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"agents",
|
||||
"channels",
|
||||
"providers",
|
||||
"gateway",
|
||||
"tools",
|
||||
"heartbeat",
|
||||
"devices"
|
||||
],
|
||||
"properties": {
|
||||
"agents": { "$ref": "#/$defs/AgentsConfig" },
|
||||
"channels": { "$ref": "#/$defs/ChannelsConfig" },
|
||||
"providers": { "$ref": "#/$defs/ProvidersConfig" },
|
||||
"gateway": { "$ref": "#/$defs/GatewayConfig" },
|
||||
"tools": { "$ref": "#/$defs/ToolsConfig" },
|
||||
"heartbeat": { "$ref": "#/$defs/HeartbeatConfig" },
|
||||
"devices": { "$ref": "#/$defs/DevicesConfig" }
|
||||
},
|
||||
"$defs": {
|
||||
"FlexibleStringSlice": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "number" }]
|
||||
},
|
||||
"description": "List of allowed sender identifiers for a channel. Each entry may be a string or a numeric ID (some chat platforms use numeric IDs).",
|
||||
"examples": [["alice", "bob", 123456789]],
|
||||
"default": []
|
||||
},
|
||||
"AgentsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["defaults"],
|
||||
"properties": {
|
||||
"defaults": { "$ref": "#/$defs/AgentDefaults" }
|
||||
}
|
||||
},
|
||||
"AgentDefaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"workspace",
|
||||
"restrict_to_workspace",
|
||||
"provider",
|
||||
"model",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"max_tool_iterations"
|
||||
],
|
||||
"properties": {
|
||||
"workspace": { "type": "string", "default": "~/.picoclaw/workspace" },
|
||||
"restrict_to_workspace": { "type": "boolean", "default": true },
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Provider key to use by default for agent requests. Must match a key under the top-level 'providers' section. Empty means provider-specific default behavior.",
|
||||
"enum": [
|
||||
"",
|
||||
"openrouter",
|
||||
"zhipu",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
"groq",
|
||||
"vllm",
|
||||
"ollama",
|
||||
"together",
|
||||
"deepinfra",
|
||||
"mistral",
|
||||
"qwen",
|
||||
"moonshot",
|
||||
"shengsuanyun",
|
||||
"deepseek",
|
||||
"github_copilot"
|
||||
],
|
||||
"examples": ["openrouter"]
|
||||
},
|
||||
"model": { "type": "string", "default": "glm-4.7" },
|
||||
"max_tokens": { "type": "integer", "minimum": 1, "default": 8192 },
|
||||
"temperature": { "type": "number", "minimum": 0, "default": 0.7 },
|
||||
"max_tool_iterations": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"feishu",
|
||||
"discord",
|
||||
"maixcam",
|
||||
"qq",
|
||||
"dingtalk",
|
||||
"slack",
|
||||
"line",
|
||||
"onebot",
|
||||
"webui"
|
||||
],
|
||||
"properties": {
|
||||
"whatsapp": { "$ref": "#/$defs/WhatsAppConfig" },
|
||||
"telegram": { "$ref": "#/$defs/TelegramConfig" },
|
||||
"feishu": { "$ref": "#/$defs/FeishuConfig" },
|
||||
"discord": { "$ref": "#/$defs/DiscordConfig" },
|
||||
"maixcam": { "$ref": "#/$defs/MaixCamConfig" },
|
||||
"qq": { "$ref": "#/$defs/QQConfig" },
|
||||
"dingtalk": { "$ref": "#/$defs/DingTalkConfig" },
|
||||
"slack": { "$ref": "#/$defs/SlackConfig" },
|
||||
"line": { "$ref": "#/$defs/LINEConfig" },
|
||||
"onebot": { "$ref": "#/$defs/OneBotConfig" },
|
||||
"webui": { "$ref": "#/$defs/WebUIConfig" }
|
||||
}
|
||||
},
|
||||
"WhatsAppConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "bridge_url", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"bridge_url": { "type": "string", "default": "ws://localhost:3001" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"TelegramConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "token", "proxy", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"token": { "type": "string", "default": "" },
|
||||
"proxy": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"FeishuConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"app_id",
|
||||
"app_secret",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
"allow_from"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"app_id": { "type": "string", "default": "" },
|
||||
"app_secret": { "type": "string", "default": "" },
|
||||
"encrypt_key": { "type": "string", "default": "" },
|
||||
"verification_token": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"DiscordConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "token", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"token": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"MaixCamConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "host", "port", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"host": { "type": "string", "default": "0.0.0.0" },
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 18790
|
||||
},
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"QQConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "app_id", "app_secret", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"app_id": { "type": "string", "default": "" },
|
||||
"app_secret": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"DingTalkConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "client_id", "client_secret", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"client_id": { "type": "string", "default": "" },
|
||||
"client_secret": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"SlackConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "bot_token", "app_token", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"bot_token": { "type": "string", "default": "" },
|
||||
"app_token": { "type": "string", "default": "" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"LINEConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"channel_secret",
|
||||
"channel_access_token",
|
||||
"webhook_host",
|
||||
"webhook_port",
|
||||
"webhook_path",
|
||||
"allow_from"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"channel_secret": { "type": "string", "default": "" },
|
||||
"channel_access_token": { "type": "string", "default": "" },
|
||||
"webhook_host": { "type": "string", "default": "0.0.0.0" },
|
||||
"webhook_port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 18791
|
||||
},
|
||||
"webhook_path": { "type": "string", "default": "/webhook/line" },
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"OneBotConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled",
|
||||
"ws_url",
|
||||
"access_token",
|
||||
"reconnect_interval",
|
||||
"group_trigger_prefix",
|
||||
"allow_from"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"ws_url": { "type": "string", "default": "ws://127.0.0.1:3001" },
|
||||
"access_token": { "type": "string", "default": "" },
|
||||
"reconnect_interval": { "type": "integer", "minimum": 0, "default": 5 },
|
||||
"group_trigger_prefix": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"default": []
|
||||
},
|
||||
"allow_from": { "$ref": "#/$defs/FlexibleStringSlice" }
|
||||
}
|
||||
},
|
||||
"WebUIConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true }
|
||||
}
|
||||
},
|
||||
"ProvidersConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"groq",
|
||||
"zhipu",
|
||||
"vllm",
|
||||
"gemini",
|
||||
"nvidia",
|
||||
"moonshot",
|
||||
"shengsuanyun",
|
||||
"deepseek",
|
||||
"github_copilot"
|
||||
],
|
||||
"properties": {
|
||||
"anthropic": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"openai": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"openrouter": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"groq": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"zhipu": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"vllm": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"gemini": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"nvidia": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"moonshot": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"shengsuanyun": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"deepseek": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"github_copilot": { "$ref": "#/$defs/ProviderConfig" }
|
||||
}
|
||||
},
|
||||
"ProviderConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["api_key", "api_base"],
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Provider API key / token.",
|
||||
"examples": ["sk-...", "Bearer ..."]
|
||||
},
|
||||
"api_base": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional API base URL override (useful for self-hosted / proxy / compatible endpoints).",
|
||||
"examples": ["https://api.openai.com/v1", "http://localhost:8000/v1"]
|
||||
},
|
||||
"proxy": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional HTTP(S) proxy URL for provider requests.",
|
||||
"examples": ["http://127.0.0.1:7890"]
|
||||
},
|
||||
"auth_method": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional provider-specific auth method override (if supported by the provider integration)."
|
||||
},
|
||||
"connect_mode": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional provider-specific connection mode (if supported by the provider integration)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GatewayConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["host", "bind", "port", "token", "admin_token"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"default": "0.0.0.0",
|
||||
"description": "Gateway host used for display/logging. The actual listen address is controlled by gateway.bind.",
|
||||
"examples": ["0.0.0.0", "127.0.0.1"]
|
||||
},
|
||||
"bind": {
|
||||
"type": "string",
|
||||
"enum": ["local", "tailnet", "all"],
|
||||
"default": "all",
|
||||
"description": "Controls the IP address the gateway binds to. local=127.0.0.1, all=0.0.0.0, tailnet=your Tailscale IP.",
|
||||
"examples": ["local", "tailnet", "all"]
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"default": 18790,
|
||||
"description": "TCP port for the gateway HTTP server.",
|
||||
"examples": [18790]
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional shared-secret token required for Web UI websocket connections. If set, the browser must connect with ?token=<secret>.",
|
||||
"examples": ["change-me"]
|
||||
},
|
||||
"admin_token": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Bearer token required for /admin/* management endpoints and the /admin web UI. If empty, /admin/* returns 401.",
|
||||
"examples": ["admin-change-me"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["web"],
|
||||
"properties": {
|
||||
"web": { "$ref": "#/$defs/WebToolsConfig" }
|
||||
}
|
||||
},
|
||||
"WebToolsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["brave", "duckduckgo"],
|
||||
"properties": {
|
||||
"brave": { "$ref": "#/$defs/BraveConfig" },
|
||||
"duckduckgo": { "$ref": "#/$defs/DuckDuckGoConfig" }
|
||||
}
|
||||
},
|
||||
"BraveConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "api_key", "max_results"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"api_key": { "type": "string", "default": "" },
|
||||
"max_results": { "type": "integer", "minimum": 1, "default": 5 }
|
||||
}
|
||||
},
|
||||
"DuckDuckGoConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "max_results"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"max_results": { "type": "integer", "minimum": 1, "default": 5 }
|
||||
}
|
||||
},
|
||||
"HeartbeatConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "interval"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"interval": { "type": "integer", "minimum": 1, "default": 30 }
|
||||
}
|
||||
},
|
||||
"DevicesConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "monitor_usb"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"monitor_usb": { "type": "boolean", "default": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ type WebUIChannel struct {
|
|||
acceptingWS atomic.Bool
|
||||
drainExitFn func(timeout time.Duration) error
|
||||
configUpdateFn func(raw []byte) error
|
||||
configReadFn func() ([]byte, error)
|
||||
}
|
||||
|
||||
type webUIClient struct {
|
||||
|
|
@ -70,6 +71,10 @@ func (c *WebUIChannel) SetConfigUpdate(fn func(raw []byte) error) {
|
|||
c.configUpdateFn = fn
|
||||
}
|
||||
|
||||
func (c *WebUIChannel) SetConfigRead(fn func() ([]byte, error)) {
|
||||
c.configReadFn = fn
|
||||
}
|
||||
|
||||
func (c *WebUIChannel) Start(ctx context.Context) error {
|
||||
addr, err := c.cfg.ResolvedAddr()
|
||||
if err != nil {
|
||||
|
|
@ -79,6 +84,7 @@ func (c *WebUIChannel) Start(ctx context.Context) error {
|
|||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", c.handleWS)
|
||||
mux.HandleFunc("/admin/config", c.handleAdminConfig)
|
||||
mux.HandleFunc("/admin/schema", c.handleAdminSchema)
|
||||
mux.HandleFunc("/admin/drain-exit", c.handleAdminDrainExit)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -291,8 +297,8 @@ func (c *WebUIChannel) isAdminAuthorized(r *http.Request) bool {
|
|||
}
|
||||
|
||||
func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.Header().Set("Allow", http.MethodPut)
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||
w.Header().Set("Allow", strings.Join([]string{http.MethodGet, http.MethodPut}, ", "))
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
|
@ -300,6 +306,23 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
if c.configReadFn == nil {
|
||||
http.Error(w, "Config read not available", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
raw, err := c.configReadFn()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(raw)
|
||||
return
|
||||
}
|
||||
|
||||
if c.configUpdateFn == nil {
|
||||
http.Error(w, "Config update not available", http.StatusNotImplemented)
|
||||
return
|
||||
|
|
@ -327,6 +350,28 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
|||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (c *WebUIChannel) handleAdminSchema(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !c.isAdminAuthorized(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := c.findConfigSchemaPath()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, "Schema not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/schema+json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(raw)
|
||||
}
|
||||
|
||||
type drainExitRequest struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
}
|
||||
|
|
@ -411,3 +456,17 @@ func (c *WebUIChannel) findUIRoot() string {
|
|||
}
|
||||
return "ui"
|
||||
}
|
||||
|
||||
func (c *WebUIChannel) findConfigSchemaPath() string {
|
||||
candidates := []string{
|
||||
filepath.Join(string(os.PathSeparator), "usr", "local", "share", "picoclaw", "config", "config.schema.json"),
|
||||
filepath.Join(string(os.PathSeparator), "usr", "share", "picoclaw", "config", "config.schema.json"),
|
||||
filepath.Join("config", "config.schema.json"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return filepath.Join("config", "config.schema.json")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
15
ui/package-lock.json
generated
15
ui/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "picoclaw-ui",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -923,6 +924,20 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
ui/src/main.js
169
ui/src/main.js
|
|
@ -1,169 +0,0 @@
|
|||
import './style.css'
|
||||
|
||||
const app = document.querySelector('#app')
|
||||
|
||||
const THEME_STORAGE_KEY = 'picoclaw.theme'
|
||||
|
||||
function getStoredTheme() {
|
||||
const v = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (v === 'light' || v === 'dark' || v === 'system') return v
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
const root = document.documentElement
|
||||
if (mode === 'system') {
|
||||
root.removeAttribute('data-theme')
|
||||
} else {
|
||||
root.setAttribute('data-theme', mode)
|
||||
}
|
||||
}
|
||||
|
||||
let themeMode = getStoredTheme()
|
||||
applyTheme(themeMode)
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div class="title">PicoClaw</div>
|
||||
<div class="header-right">
|
||||
<label class="theme" for="theme">
|
||||
<span class="theme-label">Theme</span>
|
||||
<select class="theme-select" id="theme">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="status" id="status">disconnected</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="chat" id="chat"></main>
|
||||
|
||||
<form class="composer" id="form">
|
||||
<input class="input" id="input" placeholder="Type a message..." autocomplete="off" />
|
||||
<button class="btn" id="send" type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
|
||||
const chat = document.querySelector('#chat')
|
||||
const statusEl = document.querySelector('#status')
|
||||
const themeSelect = document.querySelector('#theme')
|
||||
const form = document.querySelector('#form')
|
||||
const input = document.querySelector('#input')
|
||||
|
||||
const chatId = 'browser'
|
||||
const TOKEN_STORAGE_KEY = 'picoclaw.gateway_token'
|
||||
const urlParams = new URLSearchParams(location.search)
|
||||
const tokenFromUrl = urlParams.get('token') || ''
|
||||
const tokenFromStorage = localStorage.getItem(TOKEN_STORAGE_KEY) || ''
|
||||
let gatewayToken = tokenFromUrl || tokenFromStorage
|
||||
let tokenCameFromUrl = !!tokenFromUrl
|
||||
|
||||
function removeTokenFromUrl() {
|
||||
const p = new URLSearchParams(location.search)
|
||||
if (!p.has('token')) return
|
||||
p.delete('token')
|
||||
const qs = p.toString()
|
||||
const newUrl = `${location.pathname}${qs ? `?${qs}` : ''}${location.hash || ''}`
|
||||
history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
themeSelect.value = themeMode
|
||||
themeSelect.addEventListener('change', () => {
|
||||
themeMode = themeSelect.value
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themeMode)
|
||||
applyTheme(themeMode)
|
||||
})
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
media.addEventListener('change', () => {
|
||||
if (themeMode === 'system') applyTheme('system')
|
||||
})
|
||||
|
||||
function addMessage(role, text) {
|
||||
const item = document.createElement('div')
|
||||
item.className = `msg ${role}`
|
||||
|
||||
const bubble = document.createElement('div')
|
||||
bubble.className = 'bubble'
|
||||
bubble.textContent = text
|
||||
|
||||
item.appendChild(bubble)
|
||||
chat.appendChild(item)
|
||||
chat.scrollTop = chat.scrollHeight
|
||||
}
|
||||
|
||||
function wsUrl() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const tokenPart = gatewayToken ? `&token=${encodeURIComponent(gatewayToken)}` : ''
|
||||
return `${proto}//${location.host}/ws?chat_id=${encodeURIComponent(chatId)}${tokenPart}`
|
||||
}
|
||||
|
||||
let ws
|
||||
let reconnectTimer
|
||||
|
||||
function setStatus(s) {
|
||||
statusEl.textContent = s
|
||||
statusEl.dataset.state = s
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||
|
||||
setStatus('connecting')
|
||||
ws = new WebSocket(wsUrl())
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
setStatus('connected')
|
||||
|
||||
if (tokenCameFromUrl && gatewayToken) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, gatewayToken)
|
||||
removeTokenFromUrl()
|
||||
tokenCameFromUrl = false
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
setStatus('disconnected')
|
||||
if (!reconnectTimer) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg && msg.type === 'message' && typeof msg.content === 'string') {
|
||||
addMessage('assistant', msg.content)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
input.value = ''
|
||||
|
||||
addMessage('user', text)
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
connect()
|
||||
}
|
||||
|
||||
const payload = { chat_id: chatId, content: text }
|
||||
try {
|
||||
ws.send(JSON.stringify(payload))
|
||||
} catch {
|
||||
}
|
||||
})
|
||||
|
||||
connect()
|
||||
679
ui/src/main.ts
Normal file
679
ui/src/main.ts
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import "./style.css";
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) {
|
||||
throw new Error("Missing #app element");
|
||||
}
|
||||
|
||||
const appEl = app;
|
||||
|
||||
const THEME_STORAGE_KEY = "picoclaw.theme";
|
||||
|
||||
type ThemeMode = "system" | "light" | "dark";
|
||||
|
||||
function getStoredTheme(): ThemeMode {
|
||||
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (v === "light" || v === "dark" || v === "system") return v;
|
||||
return "system";
|
||||
}
|
||||
|
||||
function applyTheme(mode: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (mode === "system") {
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
root.setAttribute("data-theme", mode);
|
||||
}
|
||||
}
|
||||
|
||||
let themeMode: ThemeMode = getStoredTheme();
|
||||
applyTheme(themeMode);
|
||||
|
||||
const ADMIN_TOKEN_STORAGE_KEY = "picoclaw.admin_token";
|
||||
|
||||
type JSONSchema = {
|
||||
$ref?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: unknown;
|
||||
properties?: Record<string, JSONSchema>;
|
||||
required?: string[];
|
||||
items?: JSONSchema;
|
||||
enum?: unknown[];
|
||||
default?: unknown;
|
||||
examples?: unknown[];
|
||||
anyOf?: JSONSchema[];
|
||||
allOf?: JSONSchema[];
|
||||
oneOf?: JSONSchema[];
|
||||
$defs?: Record<string, JSONSchema>;
|
||||
};
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function resolveRef(root: JSONSchema, ref: string): JSONSchema | null {
|
||||
if (!ref.startsWith("#/$defs/")) return null;
|
||||
const name = ref.slice("#/$defs/".length);
|
||||
const defs = root.$defs;
|
||||
if (!defs) return null;
|
||||
return defs[name] ?? null;
|
||||
}
|
||||
|
||||
function mergeSchemas(a: JSONSchema, b: JSONSchema): JSONSchema {
|
||||
return {
|
||||
...a,
|
||||
...b,
|
||||
properties: {
|
||||
...(a.properties ?? {}),
|
||||
...(b.properties ?? {}),
|
||||
},
|
||||
required: Array.from(
|
||||
new Set([...(a.required ?? []), ...(b.required ?? [])]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSchema(root: JSONSchema, schema: JSONSchema): JSONSchema {
|
||||
let s = schema;
|
||||
if (s.$ref) {
|
||||
const r = resolveRef(root, s.$ref);
|
||||
if (r) s = mergeSchemas(r, { ...s, $ref: undefined });
|
||||
}
|
||||
if (s.allOf && s.allOf.length > 0) {
|
||||
let out: JSONSchema = { ...s, allOf: undefined };
|
||||
for (const part of s.allOf)
|
||||
out = mergeSchemas(out, normalizeSchema(root, part));
|
||||
s = out;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function getSchemaType(schema: JSONSchema): string | null {
|
||||
const t = schema.type;
|
||||
if (typeof t === "string") return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
function deepGet(obj: unknown, path: string[]): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const p of path) {
|
||||
if (!isRecord(cur)) return undefined;
|
||||
cur = cur[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function deepSet(obj: unknown, path: string[], value: unknown): void {
|
||||
if (!isRecord(obj)) return;
|
||||
let cur: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const p = path[i];
|
||||
const next = cur[p];
|
||||
if (!isRecord(next)) {
|
||||
cur[p] = {};
|
||||
}
|
||||
cur = cur[p] as Record<string, unknown>;
|
||||
}
|
||||
cur[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function renderAdminUI(): void {
|
||||
appEl.innerHTML = `
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div class="title">PicoClaw Admin</div>
|
||||
<div class="header-right">
|
||||
<label class="theme" for="theme">
|
||||
<span class="theme-label">Theme</span>
|
||||
<select class="theme-select" id="theme">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="status" id="status">idle</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin" id="admin">
|
||||
<section class="panel">
|
||||
<div class="panel-title">Connection</div>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<div class="field-label">Admin token</div>
|
||||
<input class="input" id="adminToken" placeholder="gateway.admin_token" autocomplete="off" />
|
||||
<div class="field-help">Used as Authorization: Bearer <token> for /admin/* endpoints.</div>
|
||||
</label>
|
||||
<div class="row">
|
||||
<button class="btn" id="load">Load</button>
|
||||
<button class="btn" id="save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title">Mode</div>
|
||||
<div class="row">
|
||||
<label class="radio"><input type="radio" name="mode" value="form" checked /> Form</label>
|
||||
<label class="radio"><input type="radio" name="mode" value="json" /> Raw JSON</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="jsonPanel" style="display:none">
|
||||
<div class="panel-title">Config JSON</div>
|
||||
<textarea class="textarea" id="raw"></textarea>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="formPanel">
|
||||
<div class="panel-title">Config Form</div>
|
||||
<div class="form" id="formRoot"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const statusEl = document.querySelector<HTMLDivElement>("#status");
|
||||
const themeSelect = document.querySelector<HTMLSelectElement>("#theme");
|
||||
const adminTokenInput =
|
||||
document.querySelector<HTMLInputElement>("#adminToken");
|
||||
const loadBtn = document.querySelector<HTMLButtonElement>("#load");
|
||||
const saveBtn = document.querySelector<HTMLButtonElement>("#save");
|
||||
const rawTextarea = document.querySelector<HTMLTextAreaElement>("#raw");
|
||||
const modeInputs =
|
||||
document.querySelectorAll<HTMLInputElement>("input[name=mode]");
|
||||
const jsonPanel = document.querySelector<HTMLDivElement>("#jsonPanel");
|
||||
const formPanel = document.querySelector<HTMLDivElement>("#formPanel");
|
||||
const formRoot = document.querySelector<HTMLDivElement>("#formRoot");
|
||||
|
||||
if (
|
||||
!statusEl ||
|
||||
!themeSelect ||
|
||||
!adminTokenInput ||
|
||||
!loadBtn ||
|
||||
!saveBtn ||
|
||||
!rawTextarea ||
|
||||
!jsonPanel ||
|
||||
!formPanel ||
|
||||
!formRoot
|
||||
) {
|
||||
throw new Error("Missing admin UI elements");
|
||||
}
|
||||
|
||||
const statusDiv = statusEl;
|
||||
const themeSelectEl = themeSelect;
|
||||
const adminTokenInputEl = adminTokenInput;
|
||||
const loadBtnEl = loadBtn;
|
||||
const saveBtnEl = saveBtn;
|
||||
const rawTextareaEl = rawTextarea;
|
||||
const jsonPanelEl = jsonPanel;
|
||||
const formPanelEl = formPanel;
|
||||
const formRootEl = formRoot;
|
||||
const setStatus = (s: string): void => {
|
||||
statusDiv.textContent = s;
|
||||
statusDiv.dataset.state = s;
|
||||
};
|
||||
|
||||
themeSelectEl.value = themeMode;
|
||||
themeSelectEl.addEventListener("change", () => {
|
||||
const v = themeSelectEl.value;
|
||||
if (v === "light" || v === "dark" || v === "system") {
|
||||
themeMode = v;
|
||||
} else {
|
||||
themeMode = "system";
|
||||
}
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themeMode);
|
||||
applyTheme(themeMode);
|
||||
});
|
||||
|
||||
adminTokenInputEl.value = localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) || "";
|
||||
adminTokenInputEl.addEventListener("input", () => {
|
||||
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, adminTokenInputEl.value);
|
||||
});
|
||||
|
||||
let schemaRoot: JSONSchema | null = null;
|
||||
let configObj: unknown = null;
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = adminTokenInputEl.value.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [schemaResp, cfgResp] = await Promise.all([
|
||||
fetch("/admin/schema", { headers: authHeaders() }),
|
||||
fetch("/admin/config", { headers: authHeaders() }),
|
||||
]);
|
||||
if (!schemaResp.ok)
|
||||
throw new Error(
|
||||
`schema: ${schemaResp.status} ${schemaResp.statusText}`,
|
||||
);
|
||||
if (!cfgResp.ok)
|
||||
throw new Error(`config: ${cfgResp.status} ${cfgResp.statusText}`);
|
||||
schemaRoot = (await schemaResp.json()) as JSONSchema;
|
||||
configObj = (await cfgResp.json()) as unknown;
|
||||
rawTextareaEl.value = JSON.stringify(configObj, null, 2);
|
||||
renderForm();
|
||||
setStatus("loaded");
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : "load failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll(): Promise<void> {
|
||||
setStatus("saving");
|
||||
try {
|
||||
const raw = rawTextareaEl.value.trim();
|
||||
const body =
|
||||
raw.length > 0 ? raw : JSON.stringify(configObj ?? {}, null, 2);
|
||||
const resp = await fetch("/admin/config", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(text || `${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
setStatus("saved");
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : "save failed");
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(): void {
|
||||
if (!schemaRoot || !configObj) {
|
||||
formRootEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const root = normalizeSchema(schemaRoot, schemaRoot);
|
||||
formRootEl.innerHTML = "";
|
||||
const node = renderSchemaNode(schemaRoot, root, [], configObj);
|
||||
formRootEl.appendChild(node);
|
||||
}
|
||||
|
||||
function schemaLabel(schema: JSONSchema, key: string | null): string {
|
||||
if (typeof schema.title === "string" && schema.title.trim() !== "")
|
||||
return schema.title;
|
||||
if (key) return key;
|
||||
return "value";
|
||||
}
|
||||
|
||||
function schemaHelp(schema: JSONSchema): string {
|
||||
const parts: string[] = [];
|
||||
if (
|
||||
typeof schema.description === "string" &&
|
||||
schema.description.trim() !== ""
|
||||
) {
|
||||
parts.push(schema.description.trim());
|
||||
}
|
||||
if (schema.default !== undefined) {
|
||||
parts.push(`default: ${JSON.stringify(schema.default)}`);
|
||||
}
|
||||
if (Array.isArray(schema.examples) && schema.examples.length > 0) {
|
||||
parts.push(`example: ${JSON.stringify(schema.examples[0])}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function renderSchemaNode(
|
||||
schemaRoot0: JSONSchema,
|
||||
schema0: JSONSchema,
|
||||
path: string[],
|
||||
obj: unknown,
|
||||
key: string | null = null,
|
||||
): HTMLElement {
|
||||
const schema = normalizeSchema(schemaRoot0, schema0);
|
||||
const t = getSchemaType(schema);
|
||||
const label = schemaLabel(schema, key);
|
||||
const help = schemaHelp(schema);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "node";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "node-header";
|
||||
header.textContent = label;
|
||||
wrap.appendChild(header);
|
||||
|
||||
if (help) {
|
||||
const helpEl = document.createElement("div");
|
||||
helpEl.className = "node-help";
|
||||
helpEl.textContent = help;
|
||||
wrap.appendChild(helpEl);
|
||||
}
|
||||
|
||||
if (schema.enum && Array.isArray(schema.enum)) {
|
||||
const select = document.createElement("select");
|
||||
select.className = "input";
|
||||
for (const v of schema.enum) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(v);
|
||||
opt.textContent = String(v);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
const cur = deepGet(obj, path);
|
||||
if (cur !== undefined) select.value = String(cur);
|
||||
select.addEventListener("change", () => {
|
||||
deepSet(obj, path, select.value);
|
||||
rawTextareaEl.value = JSON.stringify(configObj ?? {}, null, 2);
|
||||
});
|
||||
wrap.appendChild(select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (t === "object" && schema.properties) {
|
||||
const req = new Set(schema.required ?? []);
|
||||
const props = schema.properties;
|
||||
const keys = Object.keys(props);
|
||||
keys.sort();
|
||||
for (const k of keys) {
|
||||
const childSchema = props[k];
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
|
||||
const child = renderSchemaNode(
|
||||
schemaRoot0,
|
||||
childSchema,
|
||||
[...path, k],
|
||||
obj,
|
||||
k,
|
||||
);
|
||||
if (req.has(k)) {
|
||||
child.classList.add("required");
|
||||
}
|
||||
row.appendChild(child);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (t === "boolean") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "checkbox";
|
||||
const cur = deepGet(obj, path);
|
||||
input.checked = Boolean(cur ?? schema.default ?? false);
|
||||
input.addEventListener("change", () => {
|
||||
deepSet(obj, path, input.checked);
|
||||
rawTextareaEl.value = JSON.stringify(configObj ?? {}, null, 2);
|
||||
});
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (t === "number" || t === "integer") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "input";
|
||||
if (t === "integer") input.step = "1";
|
||||
const cur = deepGet(obj, path);
|
||||
if (typeof cur === "number") input.value = String(cur);
|
||||
else if (typeof schema.default === "number")
|
||||
input.value = String(schema.default);
|
||||
input.addEventListener("input", () => {
|
||||
const v = input.value.trim();
|
||||
if (v === "") {
|
||||
deepSet(obj, path, null);
|
||||
} else {
|
||||
const n = Number(v);
|
||||
deepSet(obj, path, Number.isFinite(n) ? n : null);
|
||||
}
|
||||
rawTextareaEl.value = JSON.stringify(configObj ?? {}, null, 2);
|
||||
});
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (t === "array" && schema.items) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "textarea";
|
||||
const cur = deepGet(obj, path);
|
||||
textarea.value = JSON.stringify(cur ?? schema.default ?? [], null, 2);
|
||||
textarea.addEventListener("input", () => {
|
||||
try {
|
||||
const v = JSON.parse(textarea.value);
|
||||
deepSet(obj, path, v);
|
||||
rawTextareaEl.value = JSON.stringify(configObj ?? {}, null, 2);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
wrap.appendChild(textarea);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// string or unknown: simple text input
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "input";
|
||||
const cur = deepGet(obj, path);
|
||||
if (typeof cur === "string") input.value = cur;
|
||||
else if (typeof schema.default === "string") input.value = schema.default;
|
||||
input.addEventListener("input", () => {
|
||||
deepSet(obj, path, input.value);
|
||||
rawTextareaEl.value = JSON.stringify(configObj ?? {}, null, 2);
|
||||
});
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function switchMode(mode: "form" | "json"): void {
|
||||
if (mode === "json") {
|
||||
jsonPanelEl.style.display = "block";
|
||||
formPanelEl.style.display = "none";
|
||||
} else {
|
||||
jsonPanelEl.style.display = "none";
|
||||
formPanelEl.style.display = "block";
|
||||
// Try to parse the JSON editor back into config when switching to form.
|
||||
try {
|
||||
configObj = JSON.parse(rawTextareaEl.value);
|
||||
renderForm();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modeInputs.forEach((i) => {
|
||||
i.addEventListener("change", () => {
|
||||
const v = i.value === "json" ? "json" : "form";
|
||||
switchMode(v);
|
||||
});
|
||||
});
|
||||
|
||||
loadBtnEl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
void loadAll();
|
||||
});
|
||||
saveBtnEl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
void saveAll();
|
||||
});
|
||||
|
||||
void loadAll();
|
||||
}
|
||||
|
||||
function renderChatUI(): void {
|
||||
appEl.innerHTML = `
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<div class="title">PicoClaw</div>
|
||||
<div class="header-right">
|
||||
<label class="theme" for="theme">
|
||||
<span class="theme-label">Theme</span>
|
||||
<select class="theme-select" id="theme">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="status" id="status">disconnected</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="chat" id="chat"></main>
|
||||
|
||||
<form class="composer" id="form">
|
||||
<input class="input" id="input" placeholder="Type a message..." autocomplete="off" />
|
||||
<button class="btn" id="send" type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const chat = document.querySelector<HTMLDivElement>("#chat");
|
||||
const statusEl = document.querySelector<HTMLDivElement>("#status");
|
||||
const themeSelect = document.querySelector<HTMLSelectElement>("#theme");
|
||||
const form = document.querySelector<HTMLFormElement>("#form");
|
||||
const input = document.querySelector<HTMLInputElement>("#input");
|
||||
|
||||
if (!chat || !statusEl || !themeSelect || !form || !input) {
|
||||
throw new Error("Missing UI elements");
|
||||
}
|
||||
|
||||
const chatEl = chat as HTMLDivElement;
|
||||
const statusDiv = statusEl as HTMLDivElement;
|
||||
|
||||
const chatId = "browser";
|
||||
const TOKEN_STORAGE_KEY = "picoclaw.gateway_token";
|
||||
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const tokenFromUrl = urlParams.get("token") || "";
|
||||
const tokenFromStorage = localStorage.getItem(TOKEN_STORAGE_KEY) || "";
|
||||
const gatewayToken = tokenFromUrl || tokenFromStorage;
|
||||
let tokenCameFromUrl = Boolean(tokenFromUrl);
|
||||
|
||||
function removeTokenFromUrl(): void {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (!p.has("token")) return;
|
||||
p.delete("token");
|
||||
const qs = p.toString();
|
||||
const newUrl = `${location.pathname}${qs ? `?${qs}` : ""}${location.hash || ""}`;
|
||||
history.replaceState(null, "", newUrl);
|
||||
}
|
||||
|
||||
themeSelect.value = themeMode;
|
||||
|
||||
themeSelect.addEventListener("change", () => {
|
||||
const v = themeSelect.value;
|
||||
if (v === "light" || v === "dark" || v === "system") {
|
||||
themeMode = v;
|
||||
} else {
|
||||
themeMode = "system";
|
||||
}
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themeMode);
|
||||
applyTheme(themeMode);
|
||||
});
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
media.addEventListener("change", () => {
|
||||
if (themeMode === "system") applyTheme("system");
|
||||
});
|
||||
|
||||
function addMessage(role: "user" | "assistant", text: string): void {
|
||||
const item = document.createElement("div");
|
||||
item.className = `msg ${role}`;
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
bubble.textContent = text;
|
||||
|
||||
item.appendChild(bubble);
|
||||
chatEl.appendChild(item);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
function wsUrl(): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const tokenPart = gatewayToken
|
||||
? `&token=${encodeURIComponent(gatewayToken)}`
|
||||
: "";
|
||||
return `${proto}//${location.host}/ws?chat_id=${encodeURIComponent(chatId)}${tokenPart}`;
|
||||
}
|
||||
|
||||
type WSMessage = { type?: unknown; content?: unknown };
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
function setStatus(s: string): void {
|
||||
statusDiv.textContent = s;
|
||||
statusDiv.dataset.state = s;
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (
|
||||
ws &&
|
||||
(ws.readyState === WebSocket.OPEN ||
|
||||
ws.readyState === WebSocket.CONNECTING)
|
||||
)
|
||||
return;
|
||||
|
||||
setStatus("connecting");
|
||||
ws = new WebSocket(wsUrl());
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
setStatus("connected");
|
||||
|
||||
if (tokenCameFromUrl && gatewayToken) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, gatewayToken);
|
||||
removeTokenFromUrl();
|
||||
tokenCameFromUrl = false;
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
setStatus("disconnected");
|
||||
if (reconnectTimer == null) {
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev: MessageEvent<string>) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as WSMessage;
|
||||
if (msg && msg.type === "message" && typeof msg.content === "string") {
|
||||
addMessage("assistant", msg.content);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
|
||||
addMessage("user", text);
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
}
|
||||
|
||||
const payload = { chat_id: chatId, content: text };
|
||||
try {
|
||||
ws?.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/admin")) {
|
||||
renderAdminUI();
|
||||
} else {
|
||||
renderChatUI();
|
||||
}
|
||||
119
ui/src/style.css
119
ui/src/style.css
|
|
@ -205,3 +205,122 @@ html, body {
|
|||
.btn:hover {
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
.admin {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel-solid);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row > .node {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
height: 38px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#raw.textarea {
|
||||
min-height: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: rgba(37, 99, 235, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.node {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(37, 99, 235, 0.04);
|
||||
}
|
||||
|
||||
.node.required > .node-header::after {
|
||||
content: " *";
|
||||
color: #ed4245;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
font-weight: 650;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.node-help {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
|
|
|||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
ui/tsconfig.json
Normal file
19
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
"strict": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue