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 built Web UI assets
|
||||||
COPY --from=builder /src/ui/dist /usr/local/share/picoclaw/ui/dist
|
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
|
# Create picoclaw home directory
|
||||||
RUN /usr/local/bin/picoclaw onboard
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -702,6 +702,8 @@ picoclaw agent -m "Hello"
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Full config example</b></summary>
|
<summary><b>Full config example</b></summary>
|
||||||
|
|
||||||
|
Schema: `config/config.schema.json`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -796,6 +798,32 @@ If you set `gateway.token`, open:
|
||||||
|
|
||||||
The listen address is controlled by `gateway.bind` (`local`, `tailnet`, `all`).
|
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)
|
### Admin API (Config Update + Graceful Restart)
|
||||||
|
|
||||||
The gateway also exposes an **API-only** management interface for remote administration.
|
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:
|
It supports:
|
||||||
|
|
||||||
- **Replace config**: `PUT /admin/config`
|
- **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`
|
- **Graceful drain + exit(0)** (so Docker/systemd can restart it): `POST /admin/drain-exit`
|
||||||
|
|
||||||
**Authentication**
|
**Authentication**
|
||||||
|
|
|
||||||
|
|
@ -735,6 +735,9 @@ func gatewayCmd() {
|
||||||
wc.SetConfigUpdate(func(raw []byte) error {
|
wc.SetConfigUpdate(func(raw []byte) error {
|
||||||
return saveConfigRawAtomic(configPath, raw)
|
return saveConfigRawAtomic(configPath, raw)
|
||||||
})
|
})
|
||||||
|
wc.SetConfigRead(func() ([]byte, error) {
|
||||||
|
return os.ReadFile(configPath)
|
||||||
|
})
|
||||||
wc.SetDrainExit(func(timeout time.Duration) error {
|
wc.SetDrainExit(func(timeout time.Duration) error {
|
||||||
shutdown(timeout)
|
shutdown(timeout)
|
||||||
os.Exit(0)
|
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
|
acceptingWS atomic.Bool
|
||||||
drainExitFn func(timeout time.Duration) error
|
drainExitFn func(timeout time.Duration) error
|
||||||
configUpdateFn func(raw []byte) error
|
configUpdateFn func(raw []byte) error
|
||||||
|
configReadFn func() ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type webUIClient struct {
|
type webUIClient struct {
|
||||||
|
|
@ -70,6 +71,10 @@ func (c *WebUIChannel) SetConfigUpdate(fn func(raw []byte) error) {
|
||||||
c.configUpdateFn = fn
|
c.configUpdateFn = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WebUIChannel) SetConfigRead(fn func() ([]byte, error)) {
|
||||||
|
c.configReadFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WebUIChannel) Start(ctx context.Context) error {
|
func (c *WebUIChannel) Start(ctx context.Context) error {
|
||||||
addr, err := c.cfg.ResolvedAddr()
|
addr, err := c.cfg.ResolvedAddr()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -79,6 +84,7 @@ func (c *WebUIChannel) Start(ctx context.Context) error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/ws", c.handleWS)
|
mux.HandleFunc("/ws", c.handleWS)
|
||||||
mux.HandleFunc("/admin/config", c.handleAdminConfig)
|
mux.HandleFunc("/admin/config", c.handleAdminConfig)
|
||||||
|
mux.HandleFunc("/admin/schema", c.handleAdminSchema)
|
||||||
mux.HandleFunc("/admin/drain-exit", c.handleAdminDrainExit)
|
mux.HandleFunc("/admin/drain-exit", c.handleAdminDrainExit)
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPut {
|
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||||
w.Header().Set("Allow", http.MethodPut)
|
w.Header().Set("Allow", strings.Join([]string{http.MethodGet, http.MethodPut}, ", "))
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +306,23 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
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 {
|
if c.configUpdateFn == nil {
|
||||||
http.Error(w, "Config update not available", http.StatusNotImplemented)
|
http.Error(w, "Config update not available", http.StatusNotImplemented)
|
||||||
return
|
return
|
||||||
|
|
@ -327,6 +350,28 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
||||||
w.Write([]byte("ok"))
|
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 {
|
type drainExitRequest struct {
|
||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
@ -411,3 +456,17 @@ func (c *WebUIChannel) findUIRoot() string {
|
||||||
}
|
}
|
||||||
return "ui"
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
15
ui/package-lock.json
generated
15
ui/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "picoclaw-ui",
|
"name": "picoclaw-ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -923,6 +924,20 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.4.0"
|
"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 {
|
.btn:hover {
|
||||||
background: rgba(37, 99, 235, 0.22);
|
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