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()
}
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 }))

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 }),

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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"));
};

View File

@@ -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>
);