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:
@@ -1175,9 +1175,7 @@ fn expand_tilde(path: &str) -> String {
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
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()?;
|
||||
fn parse_identity_content(content: &str) -> (Option<String>, Option<String>) {
|
||||
let mut name = None;
|
||||
let mut emoji = None;
|
||||
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]
|
||||
@@ -4153,9 +4157,8 @@ pub async fn sftp_remove_file(pool: State<'_, SshConnectionPool>, host_id: Strin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Value, String> {
|
||||
let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))
|
||||
pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<String, String> {
|
||||
pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -4243,7 +4246,7 @@ pub async fn remote_check_openclaw_update(
|
||||
}
|
||||
|
||||
#[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 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);
|
||||
|
||||
let mut agents = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
let default_model = cfg.pointer("/agents/defaults/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) {
|
||||
for agent in list {
|
||||
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());
|
||||
agents.push(serde_json::json!({
|
||||
"id": id,
|
||||
"name": null,
|
||||
"emoji": null,
|
||||
"model": model,
|
||||
"channels": [],
|
||||
"online": gateway_running,
|
||||
"workspace": null,
|
||||
}));
|
||||
if !seen_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let workspace = agent.get("workspace").and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| default_workspace.clone());
|
||||
|
||||
// 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)
|
||||
@@ -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> {
|
||||
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 mut bindings = Vec::new();
|
||||
if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
|
||||
for agent in list {
|
||||
let agent_id = agent.get("id").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
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 }
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let bindings = cfg
|
||||
.get("bindings")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(bindings)
|
||||
}
|
||||
|
||||
@@ -4683,18 +4695,22 @@ pub async fn remote_list_history(
|
||||
// Parse filename: {timestamp}-{source}.json
|
||||
let stem = entry.name.trim_end_matches(".json");
|
||||
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!({
|
||||
"id": entry.name,
|
||||
"createdAt": created_at.to_string(),
|
||||
"createdAt": created_at_iso,
|
||||
"source": source,
|
||||
"canRollback": true,
|
||||
"canRollback": false,
|
||||
}));
|
||||
}
|
||||
// Sort newest first
|
||||
items.sort_by(|a, b| {
|
||||
let ta = a["createdAt"].as_str().unwrap_or("0");
|
||||
let tb = b["createdAt"].as_str().unwrap_or("0");
|
||||
let ta = a["createdAt"].as_str().unwrap_or("");
|
||||
let tb = b["createdAt"].as_str().unwrap_or("");
|
||||
tb.cmp(ta)
|
||||
});
|
||||
Ok(serde_json::json!({ "items": items }))
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct SshHostConfig {
|
||||
/// "key" | "ssh_config" | "password"
|
||||
pub auth_method: String,
|
||||
pub key_path: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -175,8 +176,9 @@ impl SshConnectionPool {
|
||||
}
|
||||
}
|
||||
"password" => {
|
||||
// Password auth — password stored in key_path field for now
|
||||
let password = config.key_path.as_deref().unwrap_or("");
|
||||
let password = config.password.as_deref()
|
||||
.or(config.key_path.as_deref()) // backwards compat
|
||||
.unwrap_or("");
|
||||
session
|
||||
.authenticate_password(&username, password)
|
||||
.await
|
||||
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -116,7 +116,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
setDirty(false); // Reset dirty on instance change
|
||||
checkDirty();
|
||||
pollRef.current = setInterval(checkDirty, 2000);
|
||||
pollRef.current = setInterval(checkDirty, isRemote ? 5000 : 2000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
@@ -124,6 +124,7 @@ export function App() {
|
||||
|
||||
// Load Discord data + extract profiles on startup or instance change
|
||||
useEffect(() => {
|
||||
setDiscordGuildChannels([]);
|
||||
if (activeInstance === "local") {
|
||||
if (!localStorage.getItem("clawpal_profiles_extracted")) {
|
||||
api.extractModelProfilesFromConfig()
|
||||
@@ -131,16 +132,17 @@ export function App() {
|
||||
.catch((e) => console.error("Failed to extract model profiles:", 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));
|
||||
}
|
||||
}, [activeInstance, connectionStatus]);
|
||||
}, [activeInstance, isConnected]);
|
||||
|
||||
const bumpConfigVersion = useCallback(() => {
|
||||
setConfigVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
const handleApplyClick = () => {
|
||||
if (isRemote && !isConnected) return;
|
||||
// Load diff data for the dialog
|
||||
const checkPromise = isRemote
|
||||
? api.remoteCheckConfigDirty(activeInstance)
|
||||
@@ -354,7 +356,6 @@ export function App() {
|
||||
<Settings key={`${activeInstance}-${configVersion}`} onDataChange={bumpConfigVersion} />
|
||||
)}
|
||||
</main>
|
||||
</InstanceContext.Provider>
|
||||
|
||||
{/* Chat Panel -- inline, pushes main content */}
|
||||
{chatOpen && (
|
||||
@@ -375,6 +376,7 @@ export function App() {
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</InstanceContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ interface Message {
|
||||
const AGENT_ID = "main";
|
||||
const SESSION_KEY_PREFIX = "clawpal_chat_session_";
|
||||
|
||||
function loadSessionId(agent: string): string | undefined {
|
||||
return localStorage.getItem(SESSION_KEY_PREFIX + agent) || undefined;
|
||||
function loadSessionId(instanceId: string, agent: string): string | undefined {
|
||||
return localStorage.getItem(SESSION_KEY_PREFIX + instanceId + "_" + agent) || undefined;
|
||||
}
|
||||
function saveSessionId(agent: string, sid: string) {
|
||||
localStorage.setItem(SESSION_KEY_PREFIX + agent, sid);
|
||||
function saveSessionId(instanceId: string, agent: string, sid: string) {
|
||||
localStorage.setItem(SESSION_KEY_PREFIX + instanceId + "_" + agent, sid);
|
||||
}
|
||||
function clearSessionId(agent: string) {
|
||||
localStorage.removeItem(SESSION_KEY_PREFIX + agent);
|
||||
function clearSessionId(instanceId: string, agent: string) {
|
||||
localStorage.removeItem(SESSION_KEY_PREFIX + instanceId + "_" + agent);
|
||||
}
|
||||
|
||||
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 [agents, setAgents] = useState<string[]>([]);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setAgentId(AGENT_ID);
|
||||
setSessionId(loadSessionId(instanceId, AGENT_ID));
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemote) {
|
||||
if (!isConnected) return;
|
||||
@@ -66,6 +72,7 @@ export function Chat() {
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
if (isRemote && !isConnected) return;
|
||||
|
||||
const userMsg: Message = { role: "user", content: input.trim() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
@@ -84,7 +91,7 @@ export function Chat() {
|
||||
if (agentMeta?.sessionId) {
|
||||
const sid = agentMeta.sessionId as string;
|
||||
setSessionId(sid);
|
||||
saveSessionId(agentId, sid);
|
||||
saveSessionId(instanceId, agentId, sid);
|
||||
}
|
||||
// Extract reply text
|
||||
const payloads = result.payloads as Array<{ text?: string }> | undefined;
|
||||
@@ -95,12 +102,12 @@ export function Chat() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [input, loading, agentId, sessionId, isRemote, instanceId]);
|
||||
}, [input, loading, agentId, sessionId, isRemote, isConnected, instanceId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -114,7 +121,7 @@ export function Chat() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs opacity-70"
|
||||
onClick={() => { clearSessionId(agentId); setSessionId(undefined); setMessages([]); }}
|
||||
onClick={() => { clearSessionId(instanceId, agentId); setSessionId(undefined); setMessages([]); }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useInstance } from "@/lib/instance-context";
|
||||
import { api } from "../lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,16 +32,13 @@ export function CreateAgentDialog({
|
||||
onOpenChange,
|
||||
modelProfiles,
|
||||
onCreated,
|
||||
instanceId,
|
||||
isRemote,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modelProfiles: ModelProfile[];
|
||||
onCreated: (result: CreateAgentResult) => void;
|
||||
instanceId?: string;
|
||||
isRemote?: boolean;
|
||||
}) {
|
||||
const { instanceId, isRemote } = useInstance();
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [independent, setIndependent] = useState(false);
|
||||
|
||||
@@ -35,6 +35,7 @@ const emptyHost: Omit<SshHost, "id"> = {
|
||||
username: "",
|
||||
authMethod: "ssh_config",
|
||||
keyPath: undefined,
|
||||
password: undefined,
|
||||
};
|
||||
|
||||
export function InstanceTabBar({
|
||||
@@ -64,6 +65,7 @@ export function InstanceTabBar({
|
||||
username: host.username,
|
||||
authMethod: host.authMethod,
|
||||
keyPath: host.keyPath,
|
||||
password: host.password,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -221,7 +223,8 @@ export function InstanceTabBar({
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
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
|
||||
id="ssh-password"
|
||||
type="password"
|
||||
value={form.keyPath || ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, keyPath: e.target.value }))}
|
||||
value={form.password || ""}
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
getSystemStatus: (): Promise<SystemStatus> =>
|
||||
@@ -94,7 +94,7 @@ export const api = {
|
||||
invoke("restart_gateway", {}),
|
||||
setGlobalModel: (profileId: string | null): Promise<boolean> =>
|
||||
invoke("set_global_model", { profileId }),
|
||||
listBindings: (): Promise<Record<string, unknown>[]> =>
|
||||
listBindings: (): Promise<Binding[]> =>
|
||||
invoke("list_bindings", {}),
|
||||
assignChannelAgent: (channelType: string, peerId: string, agentId: string | null): Promise<boolean> =>
|
||||
invoke("assign_channel_agent", { channelType, peerId, agentId }),
|
||||
@@ -136,15 +136,15 @@ export const api = {
|
||||
invoke("sftp_remove_file", { hostId, path }),
|
||||
|
||||
// Remote business commands
|
||||
remoteReadRawConfig: (hostId: string): Promise<unknown> =>
|
||||
remoteReadRawConfig: (hostId: string): Promise<string> =>
|
||||
invoke("remote_read_raw_config", { hostId }),
|
||||
remoteGetSystemStatus: (hostId: string): Promise<Record<string, unknown>> =>
|
||||
remoteGetSystemStatus: (hostId: string): Promise<RemoteSystemStatus> =>
|
||||
invoke("remote_get_system_status", { hostId }),
|
||||
remoteListAgentsOverview: (hostId: string): Promise<AgentOverview[]> =>
|
||||
invoke("remote_list_agents_overview", { hostId }),
|
||||
remoteListChannelsMinimal: (hostId: string): Promise<ChannelNode[]> =>
|
||||
invoke("remote_list_channels_minimal", { hostId }),
|
||||
remoteListBindings: (hostId: string): Promise<Record<string, unknown>[]> =>
|
||||
remoteListBindings: (hostId: string): Promise<Binding[]> =>
|
||||
invoke("remote_list_bindings", { hostId }),
|
||||
remoteRestartGateway: (hostId: string): Promise<boolean> =>
|
||||
invoke("remote_restart_gateway", { hostId }),
|
||||
@@ -190,7 +190,7 @@ export const api = {
|
||||
invoke("remote_refresh_model_catalog", { hostId }),
|
||||
remoteChatViaOpenclaw: (hostId: string, agentId: string, message: string, sessionId?: string): Promise<Record<string, unknown>> =>
|
||||
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 }),
|
||||
remoteSaveConfigBaseline: (hostId: string): Promise<boolean> =>
|
||||
invoke("remote_save_config_baseline", { hostId }),
|
||||
|
||||
@@ -230,6 +230,20 @@ export interface StatusLight {
|
||||
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 {
|
||||
dirty: boolean;
|
||||
baseline: string;
|
||||
@@ -251,6 +265,7 @@ export interface SshHost {
|
||||
username: string;
|
||||
authMethod: "key" | "ssh_config" | "password";
|
||||
keyPath?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface SshExecResult {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useInstance } from "@/lib/instance-context";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -17,11 +17,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { CreateAgentDialog, type CreateAgentResult } from "@/components/CreateAgentDialog";
|
||||
|
||||
interface Binding {
|
||||
agentId: string;
|
||||
match: { channel: string; peer?: { id: string; kind: string } };
|
||||
}
|
||||
|
||||
interface AgentGroup {
|
||||
identity: string;
|
||||
emoji?: string;
|
||||
@@ -91,9 +86,9 @@ export function Channels({
|
||||
const refreshBindings = useCallback(() => {
|
||||
if (isRemote) {
|
||||
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 {
|
||||
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]);
|
||||
|
||||
@@ -120,12 +115,15 @@ export function Channels({
|
||||
refreshBindings();
|
||||
if (!isRemote) {
|
||||
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();
|
||||
refreshDiscordCache();
|
||||
}, [isRemote, refreshAgents, refreshBindings, refreshChannelNodes, refreshDiscordCache]);
|
||||
|
||||
const handleRefreshDiscord = () => {
|
||||
if (isRemote) return; // Refresh is local-only
|
||||
setRefreshing("discord");
|
||||
api.refreshDiscordGuildChannels()
|
||||
.then((channels) => {
|
||||
@@ -137,6 +135,7 @@ export function Channels({
|
||||
};
|
||||
|
||||
const handleRefreshPlatform = (platform: string) => {
|
||||
if (isRemote) return; // Refresh is local-only
|
||||
setRefreshing(platform);
|
||||
api.listChannelsMinimal()
|
||||
.then((nodes) => {
|
||||
@@ -250,14 +249,12 @@ export function Channels({
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{!isRemote && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="__new__">
|
||||
<span className="text-primary">+ New agent...</span>
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="__new__">
|
||||
<span className="text-primary">+ New agent...</span>
|
||||
</SelectItem>
|
||||
</>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -367,36 +364,33 @@ export function Channels({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Agent Dialog — local only */}
|
||||
{!isRemote && (
|
||||
<CreateAgentDialog
|
||||
open={showCreateAgent}
|
||||
onOpenChange={(open) => {
|
||||
setShowCreateAgent(open);
|
||||
if (!open) setPendingChannel(null);
|
||||
}}
|
||||
modelProfiles={modelProfiles}
|
||||
instanceId={instanceId}
|
||||
isRemote={isRemote}
|
||||
onCreated={(result: CreateAgentResult) => {
|
||||
refreshAgents();
|
||||
if (pendingChannel) {
|
||||
handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId);
|
||||
// Apply persona as systemPrompt on the channel if provided
|
||||
if (result.persona && pendingChannel.platform === "discord") {
|
||||
const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId);
|
||||
if (ch) {
|
||||
const patch = JSON.stringify({
|
||||
channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } },
|
||||
});
|
||||
api.applyConfigPatch(patch, {}).catch((e) => showToast?.(String(e), "error"));
|
||||
}
|
||||
<CreateAgentDialog
|
||||
open={showCreateAgent}
|
||||
onOpenChange={(open) => {
|
||||
setShowCreateAgent(open);
|
||||
if (!open) setPendingChannel(null);
|
||||
}}
|
||||
modelProfiles={modelProfiles}
|
||||
onCreated={(result: CreateAgentResult) => {
|
||||
refreshAgents();
|
||||
if (pendingChannel) {
|
||||
handleAssign(pendingChannel.platform, pendingChannel.peerId, result.agentId);
|
||||
if (result.persona && pendingChannel.platform === "discord") {
|
||||
const ch = discordChannels.find((c) => c.channelId === pendingChannel.peerId);
|
||||
if (ch) {
|
||||
const patch = JSON.stringify({
|
||||
channels: { discord: { guilds: { [ch.guildId]: { channels: { [ch.channelId]: { systemPrompt: result.persona } } } } } },
|
||||
});
|
||||
const patchPromise = isRemote
|
||||
? api.remoteApplyConfigPatch(instanceId, patch, {})
|
||||
: api.applyConfigPatch(patch, {});
|
||||
patchPromise.catch((e) => showToast?.(String(e), "error"));
|
||||
}
|
||||
setPendingChannel(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
setPendingChannel(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useInstance } from "@/lib/instance-context";
|
||||
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 {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -30,23 +31,10 @@ import {
|
||||
DialogTitle,
|
||||
} 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() {
|
||||
const { instanceId, isRemote, isConnected } = useInstance();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [rawOutput, setRawOutput] = useState<string | null>(null);
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
|
||||
const [sessionFiles, setSessionFiles] = useState<SessionFile[]>([]);
|
||||
const [backups, setBackups] = useState<BackupInfo[]>([]);
|
||||
const [dataMessage, setDataMessage] = useState("");
|
||||
@@ -84,17 +72,14 @@ export function Doctor() {
|
||||
}));
|
||||
}, [sessionFiles]);
|
||||
|
||||
const totalMemoryBytes = useMemo(
|
||||
() => memoryFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
|
||||
[memoryFiles],
|
||||
);
|
||||
const totalSessionBytes = useMemo(
|
||||
() => sessionFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
|
||||
[sessionFiles],
|
||||
);
|
||||
|
||||
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) => {
|
||||
// Remote doctor may return a rawOutput field instead of structured issues
|
||||
const raw = (report as unknown as Record<string, unknown>).rawOutput;
|
||||
@@ -117,9 +102,6 @@ export function Doctor() {
|
||||
.then(setSessionFiles)
|
||||
.catch(() => setDataMessage("Failed to load remote session files"));
|
||||
} else {
|
||||
api.listMemoryFiles()
|
||||
.then(setMemoryFiles)
|
||||
.catch(() => setDataMessage("Failed to load memory files"));
|
||||
api.listSessionFiles()
|
||||
.then(setSessionFiles)
|
||||
.catch(() => setDataMessage("Failed to load session files"));
|
||||
@@ -156,11 +138,13 @@ export function Doctor() {
|
||||
);
|
||||
}
|
||||
refreshData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceId, isRemote, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemote) { setBackups([]); return; }
|
||||
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
|
||||
}, []);
|
||||
}, [instanceId, isRemote]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
||||
@@ -21,10 +21,13 @@ export function History() {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const refreshHistory = () => {
|
||||
const promise = isRemote && isConnected
|
||||
? api.remoteListHistory(instanceId)
|
||||
: api.listHistory(50, 0);
|
||||
return promise
|
||||
if (isRemote) {
|
||||
if (!isConnected) return;
|
||||
return api.remoteListHistory(instanceId)
|
||||
.then((resp) => setHistory(resp.items))
|
||||
.catch(() => setMessage("Failed to load history"));
|
||||
}
|
||||
return api.listHistory(50, 0)
|
||||
.then((resp) => setHistory(resp.items))
|
||||
.catch(() => setMessage("Failed to load history"));
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
|
||||
import { RecipeCard } from "@/components/RecipeCard";
|
||||
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 { useInstance } from "@/lib/instance-context";
|
||||
|
||||
@@ -80,13 +80,10 @@ export function Home({
|
||||
const fetchStatus = useCallback(() => {
|
||||
if (isRemote) {
|
||||
if (!isConnected) return; // Wait for SSH connection
|
||||
api.remoteGetSystemStatus(instanceId).then((s) => {
|
||||
const healthy = s.healthy as boolean ?? false;
|
||||
const activeAgents = s.activeAgents as number ?? 0;
|
||||
const globalDefaultModel = s.globalDefaultModel as string | undefined;
|
||||
setStatus({ healthy, activeAgents, globalDefaultModel });
|
||||
api.remoteGetSystemStatus(instanceId).then((s: RemoteSystemStatus) => {
|
||||
setStatus({ healthy: s.healthy, activeAgents: s.activeAgents, globalDefaultModel: s.globalDefaultModel });
|
||||
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));
|
||||
} else {
|
||||
api.getStatusLight().then((s) => {
|
||||
@@ -167,7 +164,7 @@ export function Home({
|
||||
const timer = setTimeout(() => {
|
||||
if (isRemote) {
|
||||
if (!isConnected) return;
|
||||
api.remoteCheckOpeclawUpdate(instanceId).then((u) => {
|
||||
api.remoteCheckOpenclawUpdate(instanceId).then((u) => {
|
||||
setUpdateInfo({ available: u.upgradeAvailable, latest: u.latestVersion ?? undefined });
|
||||
}).catch((e) => console.error("Failed to check remote update:", e));
|
||||
} else {
|
||||
@@ -186,6 +183,7 @@ export function Home({
|
||||
}, [isRemote, isConnected, instanceId]);
|
||||
|
||||
const handleDeleteAgent = (agentId: string) => {
|
||||
if (isRemote && !isConnected) return;
|
||||
const deletePromise = isRemote
|
||||
? api.remoteDeleteAgent(instanceId, agentId)
|
||||
: api.deleteAgent(agentId);
|
||||
@@ -526,8 +524,6 @@ export function Home({
|
||||
onOpenChange={setShowCreateAgent}
|
||||
modelProfiles={modelProfiles}
|
||||
onCreated={() => refreshAgents()}
|
||||
instanceId={instanceId}
|
||||
isRemote={isRemote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user