fix: comprehensive remote/local parity review fixes

- Add RemoteSystemStatus & Binding types for type safety (no more unsafe casts)
- Add dedicated password field to SshHost (was reusing keyPath)
- Fix remote_read_raw_config to return String instead of parsed JSON Value
- Fix remote_list_history timestamps: convert Unix epoch to ISO 8601
- Add !isConnected guards: Chat send, Home delete agent, App apply changes
- Fix History fallthrough to local API when remote but disconnected
- Fix Doctor: runDoctorCmd rejects when remote+disconnected, listBackups
  skips fetch for remote, remove dead memoryFiles/totalMemoryBytes code
- Fix Channels: add isRemote guard to refresh handlers, remove local
  Binding interface in favor of shared type, remove unsafe casts
- Fix Chat: reset agentId on instance switch, add isConnected to deps
- Fix App.tsx: narrow connectionStatus dependency to isConnected only
- Use shared formatBytes from utils instead of local copy in Doctor
- CreateAgentDialog uses useInstance() context instead of props

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
zhixian
2026-02-19 10:28:46 +09:00
parent 3eacff3acd
commit 3b0a776a9f
12 changed files with 181 additions and 158 deletions

View File

@@ -1175,9 +1175,7 @@ fn expand_tilde(path: &str) -> String {
path.to_string() path.to_string()
} }
fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)> { fn parse_identity_content(content: &str) -> (Option<String>, Option<String>) {
let identity_path = std::path::Path::new(workspace).join("IDENTITY.md");
let content = fs::read_to_string(&identity_path).ok()?;
let mut name = None; let mut name = None;
let mut emoji = None; let mut emoji = None;
for line in content.lines() { for line in content.lines() {
@@ -1196,7 +1194,13 @@ fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)
} }
} }
} }
Some((name, emoji)) (name, emoji)
}
fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)> {
let identity_path = std::path::Path::new(workspace).join("IDENTITY.md");
let content = fs::read_to_string(&identity_path).ok()?;
Some(parse_identity_content(&content))
} }
#[tauri::command] #[tauri::command]
@@ -4153,9 +4157,8 @@ pub async fn sftp_remove_file(pool: State<'_, SshConnectionPool>, host_id: Strin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tauri::command] #[tauri::command]
pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Value, String> { pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<String, String> {
let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?; pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await
serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))
} }
#[tauri::command] #[tauri::command]
@@ -4243,7 +4246,7 @@ pub async fn remote_check_openclaw_update(
} }
#[tauri::command] #[tauri::command]
pub async fn remote_list_agents_overview(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<Value>, String> { pub async fn remote_list_agents_overview(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<AgentOverview>, String> {
let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?; let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?; let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
@@ -4253,23 +4256,51 @@ pub async fn remote_list_agents_overview(pool: State<'_, SshConnectionPool>, hos
.unwrap_or(false); .unwrap_or(false);
let mut agents = Vec::new(); let mut agents = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
let default_model = cfg.pointer("/agents/defaults/model") let default_model = cfg.pointer("/agents/defaults/model")
.and_then(read_model_value) .and_then(read_model_value)
.or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value)); .or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
let default_workspace = cfg.pointer("/agents/defaults/workspace")
.and_then(Value::as_str)
.map(|s| s.to_string());
let channel_nodes = collect_channel_nodes(&cfg);
if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) { if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
for agent in list { for agent in list {
let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string(); let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string();
let model = agent.get("model").and_then(read_model_value).or_else(|| default_model.clone()); if !seen_ids.insert(id.clone()) {
agents.push(serde_json::json!({ continue;
"id": id, }
"name": null,
"emoji": null, let workspace = agent.get("workspace").and_then(Value::as_str)
"model": model, .map(|s| s.to_string())
"channels": [], .or_else(|| default_workspace.clone());
"online": gateway_running,
"workspace": null, // Read IDENTITY.md from remote workspace via SFTP
})); let (name, emoji) = if let Some(ref ws) = workspace {
let identity_path = format!("{}/IDENTITY.md", ws.trim_end_matches('/'));
match pool.sftp_read(&host_id, &identity_path).await {
Ok(content) => parse_identity_content(&content),
Err(_) => (None, None),
}
} else {
(None, None)
};
let model = agent.get("model").and_then(read_model_value)
.or_else(|| default_model.clone());
let channels: Vec<String> = channel_nodes.iter()
.map(|ch| ch.path.clone())
.collect();
agents.push(AgentOverview {
id,
name,
emoji,
model,
channels,
online: gateway_running,
workspace,
});
} }
} }
Ok(agents) Ok(agents)
@@ -4286,30 +4317,11 @@ pub async fn remote_list_channels_minimal(pool: State<'_, SshConnectionPool>, ho
pub async fn remote_list_bindings(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<Value>, String> { pub async fn remote_list_bindings(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<Value>, String> {
let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?; let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?; let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
let bindings = cfg
let mut bindings = Vec::new(); .get("bindings")
if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) { .and_then(Value::as_array)
for agent in list { .cloned()
let agent_id = agent.get("id").and_then(Value::as_str).unwrap_or("").to_string(); .unwrap_or_default();
if let Some(channels_arr) = agent.get("channels").and_then(Value::as_array) {
for ch in channels_arr {
if let Some(ch_obj) = ch.as_object() {
let channel = ch_obj.get("channel").and_then(Value::as_str).unwrap_or("").to_string();
let peer = ch_obj.get("peer");
let peer_id = peer.and_then(|p| p.get("id")).and_then(Value::as_str).unwrap_or("").to_string();
let peer_kind = peer.and_then(|p| p.get("kind")).and_then(Value::as_str).unwrap_or("").to_string();
bindings.push(serde_json::json!({
"agentId": agent_id,
"match": {
"channel": channel,
"peer": { "id": peer_id, "kind": peer_kind }
}
}));
}
}
}
}
}
Ok(bindings) Ok(bindings)
} }
@@ -4683,18 +4695,22 @@ pub async fn remote_list_history(
// Parse filename: {timestamp}-{source}.json // Parse filename: {timestamp}-{source}.json
let stem = entry.name.trim_end_matches(".json"); let stem = entry.name.trim_end_matches(".json");
let (ts_str, source) = stem.split_once('-').unwrap_or((stem, "unknown")); let (ts_str, source) = stem.split_once('-').unwrap_or((stem, "unknown"));
let created_at = ts_str.parse::<u64>().unwrap_or(0); let created_at = ts_str.parse::<i64>().unwrap_or(0);
// Convert Unix timestamp to ISO 8601 format for frontend compatibility
let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| created_at.to_string());
items.push(serde_json::json!({ items.push(serde_json::json!({
"id": entry.name, "id": entry.name,
"createdAt": created_at.to_string(), "createdAt": created_at_iso,
"source": source, "source": source,
"canRollback": true, "canRollback": false,
})); }));
} }
// Sort newest first // Sort newest first
items.sort_by(|a, b| { items.sort_by(|a, b| {
let ta = a["createdAt"].as_str().unwrap_or("0"); let ta = a["createdAt"].as_str().unwrap_or("");
let tb = b["createdAt"].as_str().unwrap_or("0"); let tb = b["createdAt"].as_str().unwrap_or("");
tb.cmp(ta) tb.cmp(ta)
}); });
Ok(serde_json::json!({ "items": items })) Ok(serde_json::json!({ "items": items }))

View File

@@ -24,6 +24,7 @@ pub struct SshHostConfig {
/// "key" | "ssh_config" | "password" /// "key" | "ssh_config" | "password"
pub auth_method: String, pub auth_method: String,
pub key_path: Option<String>, pub key_path: Option<String>,
pub password: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -175,8 +176,9 @@ impl SshConnectionPool {
} }
} }
"password" => { "password" => {
// Password auth — password stored in key_path field for now let password = config.password.as_deref()
let password = config.key_path.as_deref().unwrap_or(""); .or(config.key_path.as_deref()) // backwards compat
.unwrap_or("");
session session
.authenticate_password(&username, password) .authenticate_password(&username, password)
.await .await

View File

@@ -116,7 +116,7 @@ export function App() {
useEffect(() => { useEffect(() => {
setDirty(false); // Reset dirty on instance change setDirty(false); // Reset dirty on instance change
checkDirty(); checkDirty();
pollRef.current = setInterval(checkDirty, 2000); pollRef.current = setInterval(checkDirty, isRemote ? 5000 : 2000);
return () => { return () => {
if (pollRef.current) clearInterval(pollRef.current); if (pollRef.current) clearInterval(pollRef.current);
}; };
@@ -124,6 +124,7 @@ export function App() {
// Load Discord data + extract profiles on startup or instance change // Load Discord data + extract profiles on startup or instance change
useEffect(() => { useEffect(() => {
setDiscordGuildChannels([]);
if (activeInstance === "local") { if (activeInstance === "local") {
if (!localStorage.getItem("clawpal_profiles_extracted")) { if (!localStorage.getItem("clawpal_profiles_extracted")) {
api.extractModelProfilesFromConfig() api.extractModelProfilesFromConfig()
@@ -131,16 +132,17 @@ export function App() {
.catch((e) => console.error("Failed to extract model profiles:", e)); .catch((e) => console.error("Failed to extract model profiles:", e));
} }
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch((e) => console.error("Failed to load Discord channels:", e)); api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch((e) => console.error("Failed to load Discord channels:", e));
} else if (connectionStatus[activeInstance] === "connected") { } else if (isConnected) {
api.remoteListDiscordGuildChannels(activeInstance).then(setDiscordGuildChannels).catch((e) => console.error("Failed to load remote Discord channels:", e)); api.remoteListDiscordGuildChannels(activeInstance).then(setDiscordGuildChannels).catch((e) => console.error("Failed to load remote Discord channels:", e));
} }
}, [activeInstance, connectionStatus]); }, [activeInstance, isConnected]);
const bumpConfigVersion = useCallback(() => { const bumpConfigVersion = useCallback(() => {
setConfigVersion((v) => v + 1); setConfigVersion((v) => v + 1);
}, []); }, []);
const handleApplyClick = () => { const handleApplyClick = () => {
if (isRemote && !isConnected) return;
// Load diff data for the dialog // Load diff data for the dialog
const checkPromise = isRemote const checkPromise = isRemote
? api.remoteCheckConfigDirty(activeInstance) ? api.remoteCheckConfigDirty(activeInstance)
@@ -354,7 +356,6 @@ export function App() {
<Settings key={`${activeInstance}-${configVersion}`} onDataChange={bumpConfigVersion} /> <Settings key={`${activeInstance}-${configVersion}`} onDataChange={bumpConfigVersion} />
)} )}
</main> </main>
</InstanceContext.Provider>
{/* Chat Panel -- inline, pushes main content */} {/* Chat Panel -- inline, pushes main content */}
{chatOpen && ( {chatOpen && (
@@ -375,6 +376,7 @@ export function App() {
</div> </div>
</aside> </aside>
)} )}
</InstanceContext.Provider>
</div> </div>
</div> </div>

View File

@@ -21,14 +21,14 @@ interface Message {
const AGENT_ID = "main"; const AGENT_ID = "main";
const SESSION_KEY_PREFIX = "clawpal_chat_session_"; const SESSION_KEY_PREFIX = "clawpal_chat_session_";
function loadSessionId(agent: string): string | undefined { function loadSessionId(instanceId: string, agent: string): string | undefined {
return localStorage.getItem(SESSION_KEY_PREFIX + agent) || undefined; return localStorage.getItem(SESSION_KEY_PREFIX + instanceId + "_" + agent) || undefined;
} }
function saveSessionId(agent: string, sid: string) { function saveSessionId(instanceId: string, agent: string, sid: string) {
localStorage.setItem(SESSION_KEY_PREFIX + agent, sid); localStorage.setItem(SESSION_KEY_PREFIX + instanceId + "_" + agent, sid);
} }
function clearSessionId(agent: string) { function clearSessionId(instanceId: string, agent: string) {
localStorage.removeItem(SESSION_KEY_PREFIX + agent); localStorage.removeItem(SESSION_KEY_PREFIX + instanceId + "_" + agent);
} }
const CLAWPAL_CONTEXT = `[ClawPal Context] You are responding inside ClawPal, a desktop GUI for OpenClaw configuration. const CLAWPAL_CONTEXT = `[ClawPal Context] You are responding inside ClawPal, a desktop GUI for OpenClaw configuration.
@@ -46,9 +46,15 @@ export function Chat() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [agents, setAgents] = useState<string[]>([]); const [agents, setAgents] = useState<string[]>([]);
const [agentId, setAgentId] = useState(AGENT_ID); const [agentId, setAgentId] = useState(AGENT_ID);
const [sessionId, setSessionId] = useState<string | undefined>(() => loadSessionId(AGENT_ID)); const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMessages([]);
setAgentId(AGENT_ID);
setSessionId(loadSessionId(instanceId, AGENT_ID));
}, [instanceId]);
useEffect(() => { useEffect(() => {
if (isRemote) { if (isRemote) {
if (!isConnected) return; if (!isConnected) return;
@@ -66,6 +72,7 @@ export function Chat() {
const send = useCallback(async () => { const send = useCallback(async () => {
if (!input.trim() || loading) return; if (!input.trim() || loading) return;
if (isRemote && !isConnected) return;
const userMsg: Message = { role: "user", content: input.trim() }; const userMsg: Message = { role: "user", content: input.trim() };
setMessages((prev) => [...prev, userMsg]); setMessages((prev) => [...prev, userMsg]);
@@ -84,7 +91,7 @@ export function Chat() {
if (agentMeta?.sessionId) { if (agentMeta?.sessionId) {
const sid = agentMeta.sessionId as string; const sid = agentMeta.sessionId as string;
setSessionId(sid); setSessionId(sid);
saveSessionId(agentId, sid); saveSessionId(instanceId, agentId, sid);
} }
// Extract reply text // Extract reply text
const payloads = result.payloads as Array<{ text?: string }> | undefined; const payloads = result.payloads as Array<{ text?: string }> | undefined;
@@ -95,12 +102,12 @@ export function Chat() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [input, loading, agentId, sessionId, isRemote, instanceId]); }, [input, loading, agentId, sessionId, isRemote, isConnected, instanceId]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Select value={agentId} onValueChange={(a) => { setAgentId(a); setSessionId(loadSessionId(a)); setMessages([]); }}> <Select value={agentId} onValueChange={(a) => { setAgentId(a); setSessionId(loadSessionId(instanceId, a)); setMessages([]); }}>
<SelectTrigger size="sm" className="w-auto text-xs"> <SelectTrigger size="sm" className="w-auto text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -114,7 +121,7 @@ export function Chat() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-xs opacity-70" className="text-xs opacity-70"
onClick={() => { clearSessionId(agentId); setSessionId(undefined); setMessages([]); }} onClick={() => { clearSessionId(instanceId, agentId); setSessionId(undefined); setMessages([]); }}
> >
New New
</Button> </Button>

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useInstance } from "@/lib/instance-context";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -31,16 +32,13 @@ export function CreateAgentDialog({
onOpenChange, onOpenChange,
modelProfiles, modelProfiles,
onCreated, onCreated,
instanceId,
isRemote,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
modelProfiles: ModelProfile[]; modelProfiles: ModelProfile[];
onCreated: (result: CreateAgentResult) => void; onCreated: (result: CreateAgentResult) => void;
instanceId?: string;
isRemote?: boolean;
}) { }) {
const { instanceId, isRemote } = useInstance();
const [agentId, setAgentId] = useState(""); const [agentId, setAgentId] = useState("");
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [independent, setIndependent] = useState(false); const [independent, setIndependent] = useState(false);

View File

@@ -35,6 +35,7 @@ const emptyHost: Omit<SshHost, "id"> = {
username: "", username: "",
authMethod: "ssh_config", authMethod: "ssh_config",
keyPath: undefined, keyPath: undefined,
password: undefined,
}; };
export function InstanceTabBar({ export function InstanceTabBar({
@@ -64,6 +65,7 @@ export function InstanceTabBar({
username: host.username, username: host.username,
authMethod: host.authMethod, authMethod: host.authMethod,
keyPath: host.keyPath, keyPath: host.keyPath,
password: host.password,
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -221,7 +223,8 @@ export function InstanceTabBar({
setForm((f) => ({ setForm((f) => ({
...f, ...f,
authMethod: val as SshHost["authMethod"], authMethod: val as SshHost["authMethod"],
keyPath: val === "ssh_config" ? undefined : (val !== f.authMethod ? "" : f.keyPath), keyPath: val === "key" ? (f.authMethod === "key" ? f.keyPath : "") : undefined,
password: val === "password" ? (f.authMethod === "password" ? f.password : "") : undefined,
})) }))
} }
> >
@@ -252,9 +255,12 @@ export function InstanceTabBar({
<Input <Input
id="ssh-password" id="ssh-password"
type="password" type="password"
value={form.keyPath || ""} value={form.password || ""}
onChange={(e) => setForm((f) => ({ ...f, keyPath: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
/> />
<p className="text-xs text-yellow-600 dark:text-yellow-400">
Password is stored in plaintext. Prefer SSH key or SSH config for better security.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { AgentOverview, AgentSessionAnalysis, ApplyResult, BackupInfo, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile, SshHost, SshExecResult, SftpEntry } from "./types"; import type { AgentOverview, AgentSessionAnalysis, ApplyResult, BackupInfo, Binding, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, RemoteSystemStatus, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile, SshHost, SshExecResult, SftpEntry } from "./types";
export const api = { export const api = {
getSystemStatus: (): Promise<SystemStatus> => getSystemStatus: (): Promise<SystemStatus> =>
@@ -94,7 +94,7 @@ export const api = {
invoke("restart_gateway", {}), invoke("restart_gateway", {}),
setGlobalModel: (profileId: string | null): Promise<boolean> => setGlobalModel: (profileId: string | null): Promise<boolean> =>
invoke("set_global_model", { profileId }), invoke("set_global_model", { profileId }),
listBindings: (): Promise<Record<string, unknown>[]> => listBindings: (): Promise<Binding[]> =>
invoke("list_bindings", {}), invoke("list_bindings", {}),
assignChannelAgent: (channelType: string, peerId: string, agentId: string | null): Promise<boolean> => assignChannelAgent: (channelType: string, peerId: string, agentId: string | null): Promise<boolean> =>
invoke("assign_channel_agent", { channelType, peerId, agentId }), invoke("assign_channel_agent", { channelType, peerId, agentId }),
@@ -136,15 +136,15 @@ export const api = {
invoke("sftp_remove_file", { hostId, path }), invoke("sftp_remove_file", { hostId, path }),
// Remote business commands // Remote business commands
remoteReadRawConfig: (hostId: string): Promise<unknown> => remoteReadRawConfig: (hostId: string): Promise<string> =>
invoke("remote_read_raw_config", { hostId }), invoke("remote_read_raw_config", { hostId }),
remoteGetSystemStatus: (hostId: string): Promise<Record<string, unknown>> => remoteGetSystemStatus: (hostId: string): Promise<RemoteSystemStatus> =>
invoke("remote_get_system_status", { hostId }), invoke("remote_get_system_status", { hostId }),
remoteListAgentsOverview: (hostId: string): Promise<AgentOverview[]> => remoteListAgentsOverview: (hostId: string): Promise<AgentOverview[]> =>
invoke("remote_list_agents_overview", { hostId }), invoke("remote_list_agents_overview", { hostId }),
remoteListChannelsMinimal: (hostId: string): Promise<ChannelNode[]> => remoteListChannelsMinimal: (hostId: string): Promise<ChannelNode[]> =>
invoke("remote_list_channels_minimal", { hostId }), invoke("remote_list_channels_minimal", { hostId }),
remoteListBindings: (hostId: string): Promise<Record<string, unknown>[]> => remoteListBindings: (hostId: string): Promise<Binding[]> =>
invoke("remote_list_bindings", { hostId }), invoke("remote_list_bindings", { hostId }),
remoteRestartGateway: (hostId: string): Promise<boolean> => remoteRestartGateway: (hostId: string): Promise<boolean> =>
invoke("remote_restart_gateway", { hostId }), invoke("remote_restart_gateway", { hostId }),
@@ -190,7 +190,7 @@ export const api = {
invoke("remote_refresh_model_catalog", { hostId }), invoke("remote_refresh_model_catalog", { hostId }),
remoteChatViaOpenclaw: (hostId: string, agentId: string, message: string, sessionId?: string): Promise<Record<string, unknown>> => remoteChatViaOpenclaw: (hostId: string, agentId: string, message: string, sessionId?: string): Promise<Record<string, unknown>> =>
invoke("remote_chat_via_openclaw", { hostId, agentId, message, sessionId }), invoke("remote_chat_via_openclaw", { hostId, agentId, message, sessionId }),
remoteCheckOpeclawUpdate: (hostId: string): Promise<{ upgradeAvailable: boolean; latestVersion: string | null; installedVersion: string }> => remoteCheckOpenclawUpdate: (hostId: string): Promise<{ upgradeAvailable: boolean; latestVersion: string | null; installedVersion: string }> =>
invoke("remote_check_openclaw_update", { hostId }), invoke("remote_check_openclaw_update", { hostId }),
remoteSaveConfigBaseline: (hostId: string): Promise<boolean> => remoteSaveConfigBaseline: (hostId: string): Promise<boolean> =>
invoke("remote_save_config_baseline", { hostId }), invoke("remote_save_config_baseline", { hostId }),

View File

@@ -230,6 +230,20 @@ export interface StatusLight {
globalDefaultModel?: string; globalDefaultModel?: string;
} }
export interface RemoteSystemStatus {
healthy: boolean;
openclawVersion: string;
activeAgents: number;
globalDefaultModel?: string;
configPath: string;
openclawDir: string;
}
export interface Binding {
agentId: string;
match: { channel: string; peer?: { id: string; kind: string } };
}
export interface ConfigDirtyState { export interface ConfigDirtyState {
dirty: boolean; dirty: boolean;
baseline: string; baseline: string;
@@ -251,6 +265,7 @@ export interface SshHost {
username: string; username: string;
authMethod: "key" | "ssh_config" | "password"; authMethod: "key" | "ssh_config" | "password";
keyPath?: string; keyPath?: string;
password?: string;
} }
export interface SshExecResult { export interface SshExecResult {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { AgentOverview, ChannelNode, DiscordGuildChannel, ModelProfile } from "../lib/types"; import type { AgentOverview, Binding, ChannelNode, DiscordGuildChannel, ModelProfile } from "../lib/types";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useInstance } from "@/lib/instance-context"; import { useInstance } from "@/lib/instance-context";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -17,11 +17,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { CreateAgentDialog, type CreateAgentResult } from "@/components/CreateAgentDialog"; import { CreateAgentDialog, type CreateAgentResult } from "@/components/CreateAgentDialog";
interface Binding {
agentId: string;
match: { channel: string; peer?: { id: string; kind: string } };
}
interface AgentGroup { interface AgentGroup {
identity: string; identity: string;
emoji?: string; emoji?: string;
@@ -91,9 +86,9 @@ export function Channels({
const refreshBindings = useCallback(() => { const refreshBindings = useCallback(() => {
if (isRemote) { if (isRemote) {
if (!isConnected) return; if (!isConnected) return;
api.remoteListBindings(instanceId).then((b) => setBindings(b as unknown as Binding[])).catch((e) => console.error("Failed to load remote bindings:", e)); api.remoteListBindings(instanceId).then((b) => setBindings(b)).catch((e) => console.error("Failed to load remote bindings:", e));
} else { } else {
api.listBindings().then((b) => setBindings(b as unknown as Binding[])).catch((e) => console.error("Failed to load bindings:", e)); api.listBindings().then((b) => setBindings(b)).catch((e) => console.error("Failed to load bindings:", e));
} }
}, [isRemote, isConnected, instanceId]); }, [isRemote, isConnected, instanceId]);
@@ -120,12 +115,15 @@ export function Channels({
refreshBindings(); refreshBindings();
if (!isRemote) { if (!isRemote) {
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e)); api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
} else if (isRemote && isConnected) {
api.remoteListModelProfiles(instanceId).then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load remote model profiles:", e));
} }
refreshChannelNodes(); refreshChannelNodes();
refreshDiscordCache(); refreshDiscordCache();
}, [isRemote, refreshAgents, refreshBindings, refreshChannelNodes, refreshDiscordCache]); }, [isRemote, refreshAgents, refreshBindings, refreshChannelNodes, refreshDiscordCache]);
const handleRefreshDiscord = () => { const handleRefreshDiscord = () => {
if (isRemote) return; // Refresh is local-only
setRefreshing("discord"); setRefreshing("discord");
api.refreshDiscordGuildChannels() api.refreshDiscordGuildChannels()
.then((channels) => { .then((channels) => {
@@ -137,6 +135,7 @@ export function Channels({
}; };
const handleRefreshPlatform = (platform: string) => { const handleRefreshPlatform = (platform: string) => {
if (isRemote) return; // Refresh is local-only
setRefreshing(platform); setRefreshing(platform);
api.listChannelsMinimal() api.listChannelsMinimal()
.then((nodes) => { .then((nodes) => {
@@ -250,14 +249,12 @@ export function Channels({
))} ))}
</SelectGroup> </SelectGroup>
))} ))}
{!isRemote && ( <>
<> <SelectSeparator />
<SelectSeparator /> <SelectItem value="__new__">
<SelectItem value="__new__"> <span className="text-primary">+ New agent...</span>
<span className="text-primary">+ New agent...</span> </SelectItem>
</SelectItem> </>
</>
)}
</SelectContent> </SelectContent>
</Select> </Select>
); );
@@ -367,36 +364,33 @@ export function Channels({
))} ))}
</div> </div>
{/* Create Agent Dialog — local only */} <CreateAgentDialog
{!isRemote && ( open={showCreateAgent}
<CreateAgentDialog onOpenChange={(open) => {
open={showCreateAgent} setShowCreateAgent(open);
onOpenChange={(open) => { if (!open) setPendingChannel(null);
setShowCreateAgent(open); }}
if (!open) setPendingChannel(null); modelProfiles={modelProfiles}
}} onCreated={(result: CreateAgentResult) => {
modelProfiles={modelProfiles} refreshAgents();
instanceId={instanceId} if (pendingChannel) {
isRemote={isRemote} handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId);
onCreated={(result: CreateAgentResult) => { if (result.persona && pendingChannel.platform === "discord") {
refreshAgents(); const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId);
if (pendingChannel) { if (ch) {
handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId); const patch = JSON.stringify({
// Apply persona as systemPrompt on the channel if provided channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } },
if (result.persona && pendingChannel.platform === "discord") { });
const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId); const patchPromise = isRemote
if (ch) { ? api.remoteApplyConfigPatch(instanceId, patch, {})
const patch = JSON.stringify({ : api.applyConfigPatch(patch, {});
channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } }, patchPromise.catch((e) => showToast?.(String(e), "error"));
});
api.applyConfigPatch(patch, {}).catch((e) => showToast?.(String(e), "error"));
}
} }
setPendingChannel(null);
} }
}} setPendingChannel(null);
/> }
)} }}
/>
</section> </section>
); );
} }

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo, useReducer, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useInstance } from "@/lib/instance-context"; import { useInstance } from "@/lib/instance-context";
import { initialState, reducer } from "@/lib/state"; import { initialState, reducer } from "@/lib/state";
import type { AgentSessionAnalysis, BackupInfo, MemoryFile, SessionFile } from "@/lib/types"; import { formatBytes } from "@/lib/utils";
import type { AgentSessionAnalysis, BackupInfo, SessionFile } from "@/lib/types";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -30,23 +31,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
function formatBytes(bytes: number) {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(1)} ${units[index]}`;
}
export function Doctor() { export function Doctor() {
const { instanceId, isRemote, isConnected } = useInstance(); const { instanceId, isRemote, isConnected } = useInstance();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const [rawOutput, setRawOutput] = useState<string | null>(null); const [rawOutput, setRawOutput] = useState<string | null>(null);
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
const [sessionFiles, setSessionFiles] = useState<SessionFile[]>([]); const [sessionFiles, setSessionFiles] = useState<SessionFile[]>([]);
const [backups, setBackups] = useState<BackupInfo[]>([]); const [backups, setBackups] = useState<BackupInfo[]>([]);
const [dataMessage, setDataMessage] = useState(""); const [dataMessage, setDataMessage] = useState("");
@@ -84,17 +72,14 @@ export function Doctor() {
})); }));
}, [sessionFiles]); }, [sessionFiles]);
const totalMemoryBytes = useMemo(
() => memoryFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
[memoryFiles],
);
const totalSessionBytes = useMemo( const totalSessionBytes = useMemo(
() => sessionFiles.reduce((sum, f) => sum + f.sizeBytes, 0), () => sessionFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
[sessionFiles], [sessionFiles],
); );
function runDoctorCmd(): Promise<import("@/lib/types").DoctorReport> { function runDoctorCmd(): Promise<import("@/lib/types").DoctorReport> {
if (isRemote && isConnected) { if (isRemote) {
if (!isConnected) return Promise.reject("Not connected");
return api.remoteRunDoctor(instanceId).then((report) => { return api.remoteRunDoctor(instanceId).then((report) => {
// Remote doctor may return a rawOutput field instead of structured issues // Remote doctor may return a rawOutput field instead of structured issues
const raw = (report as unknown as Record<string, unknown>).rawOutput; const raw = (report as unknown as Record<string, unknown>).rawOutput;
@@ -117,9 +102,6 @@ export function Doctor() {
.then(setSessionFiles) .then(setSessionFiles)
.catch(() => setDataMessage("Failed to load remote session files")); .catch(() => setDataMessage("Failed to load remote session files"));
} else { } else {
api.listMemoryFiles()
.then(setMemoryFiles)
.catch(() => setDataMessage("Failed to load memory files"));
api.listSessionFiles() api.listSessionFiles()
.then(setSessionFiles) .then(setSessionFiles)
.catch(() => setDataMessage("Failed to load session files")); .catch(() => setDataMessage("Failed to load session files"));
@@ -156,11 +138,13 @@ export function Doctor() {
); );
} }
refreshData(); refreshData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, isRemote, isConnected]); }, [instanceId, isRemote, isConnected]);
useEffect(() => { useEffect(() => {
if (isRemote) { setBackups([]); return; }
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e)); api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
}, []); }, [instanceId, isRemote]);
return ( return (
<section> <section>

View File

@@ -21,10 +21,13 @@ export function History() {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const refreshHistory = () => { const refreshHistory = () => {
const promise = isRemote && isConnected if (isRemote) {
? api.remoteListHistory(instanceId) if (!isConnected) return;
: api.listHistory(50, 0); return api.remoteListHistory(instanceId)
return promise .then((resp) => setHistory(resp.items))
.catch(() => setMessage("Failed to load history"));
}
return api.listHistory(50, 0)
.then((resp) => setHistory(resp.items)) .then((resp) => setHistory(resp.items))
.catch(() => setMessage("Failed to load history")); .catch(() => setMessage("Failed to load history"));
}; };

View File

@@ -24,7 +24,7 @@ import {
import { CreateAgentDialog } from "@/components/CreateAgentDialog"; import { CreateAgentDialog } from "@/components/CreateAgentDialog";
import { RecipeCard } from "@/components/RecipeCard"; import { RecipeCard } from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile } from "../lib/types"; import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile, RemoteSystemStatus } from "../lib/types";
import { formatTime, formatBytes } from "@/lib/utils"; import { formatTime, formatBytes } from "@/lib/utils";
import { useInstance } from "@/lib/instance-context"; import { useInstance } from "@/lib/instance-context";
@@ -80,13 +80,10 @@ export function Home({
const fetchStatus = useCallback(() => { const fetchStatus = useCallback(() => {
if (isRemote) { if (isRemote) {
if (!isConnected) return; // Wait for SSH connection if (!isConnected) return; // Wait for SSH connection
api.remoteGetSystemStatus(instanceId).then((s) => { api.remoteGetSystemStatus(instanceId).then((s: RemoteSystemStatus) => {
const healthy = s.healthy as boolean ?? false; setStatus({ healthy: s.healthy, activeAgents: s.activeAgents, globalDefaultModel: s.globalDefaultModel });
const activeAgents = s.activeAgents as number ?? 0;
const globalDefaultModel = s.globalDefaultModel as string | undefined;
setStatus({ healthy, activeAgents, globalDefaultModel });
setStatusSettled(true); setStatusSettled(true);
if (s.openclawVersion) setVersion(s.openclawVersion as string); if (s.openclawVersion) setVersion(s.openclawVersion);
}).catch((e) => console.error("Failed to fetch remote status:", e)); }).catch((e) => console.error("Failed to fetch remote status:", e));
} else { } else {
api.getStatusLight().then((s) => { api.getStatusLight().then((s) => {
@@ -167,7 +164,7 @@ export function Home({
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (isRemote) { if (isRemote) {
if (!isConnected) return; if (!isConnected) return;
api.remoteCheckOpeclawUpdate(instanceId).then((u) => { api.remoteCheckOpenclawUpdate(instanceId).then((u) => {
setUpdateInfo({ available: u.upgradeAvailable, latest: u.latestVersion ?? undefined }); setUpdateInfo({ available: u.upgradeAvailable, latest: u.latestVersion ?? undefined });
}).catch((e) => console.error("Failed to check remote update:", e)); }).catch((e) => console.error("Failed to check remote update:", e));
} else { } else {
@@ -186,6 +183,7 @@ export function Home({
}, [isRemote, isConnected, instanceId]); }, [isRemote, isConnected, instanceId]);
const handleDeleteAgent = (agentId: string) => { const handleDeleteAgent = (agentId: string) => {
if (isRemote && !isConnected) return;
const deletePromise = isRemote const deletePromise = isRemote
? api.remoteDeleteAgent(instanceId, agentId) ? api.remoteDeleteAgent(instanceId, agentId)
: api.deleteAgent(agentId); : api.deleteAgent(agentId);
@@ -526,8 +524,6 @@ export function Home({
onOpenChange={setShowCreateAgent} onOpenChange={setShowCreateAgent}
modelProfiles={modelProfiles} modelProfiles={modelProfiles}
onCreated={() => refreshAgents()} onCreated={() => refreshAgents()}
instanceId={instanceId}
isRemote={isRemote}
/> />
</div> </div>
); );