feat(openclaw): add Workspace Files panel for managing bootstrap md files

Add a dedicated panel to read/write OpenClaw's 6 workspace bootstrap files
(AGENTS.md, SOUL.md, USER.md, IDENTITY.md, TOOLS.md, MEMORY.md) directly
from ~/.openclaw/workspace/, with a whitelist-secured backend and Markdown
editor UI. Also fix prompt auto-import missing OpenCode/OpenClaw and the
PromptFormPanel filenameMap type exclusion.
This commit is contained in:
Jason
2026-02-07 21:42:36 +08:00
parent 182015264c
commit 705cc8a5af
13 changed files with 360 additions and 4 deletions

View File

@@ -20,6 +20,7 @@ mod stream_check;
mod sync_support;
mod usage;
mod webdav_sync;
mod workspace;
pub use config::*;
pub use deeplink::*;
@@ -40,3 +41,4 @@ pub use skill::*;
pub use stream_check::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;

View File

@@ -0,0 +1,59 @@
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
/// Allowed workspace filenames (whitelist for security)
const ALLOWED_FILES: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"USER.md",
"IDENTITY.md",
"TOOLS.md",
"MEMORY.md",
];
fn validate_filename(filename: &str) -> Result<(), String> {
if !ALLOWED_FILES.contains(&filename) {
return Err(format!(
"Invalid workspace filename: {filename}. Allowed: {}",
ALLOWED_FILES.join(", ")
));
}
Ok(())
}
/// Read an OpenClaw workspace file content.
/// Returns None if the file does not exist.
#[tauri::command]
pub async fn read_workspace_file(filename: String) -> Result<Option<String>, String> {
validate_filename(&filename)?;
let path = get_openclaw_dir().join("workspace").join(&filename);
if !path.exists() {
return Ok(None);
}
std::fs::read_to_string(&path).map(Some).map_err(|e| {
format!("Failed to read workspace file {filename}: {e}")
})
}
/// Write content to an OpenClaw workspace file (atomic write).
/// Creates the workspace directory if it does not exist.
#[tauri::command]
pub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> {
validate_filename(&filename)?;
let workspace_dir = get_openclaw_dir().join("workspace");
// Ensure workspace directory exists
std::fs::create_dir_all(&workspace_dir).map_err(|e| {
format!("Failed to create workspace directory: {e}")
})?;
let path = workspace_dir.join(&filename);
write_text_file(&path, &content).map_err(|e| {
format!("Failed to write workspace file {filename}: {e}")
})
}

View File

@@ -582,6 +582,8 @@ pub fn run() {
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -1023,6 +1025,9 @@ pub fn run() {
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
// Workspace files (OpenClaw)
commands::read_workspace_file,
commands::write_workspace_file,
]);
let app = builder

View File

@@ -16,6 +16,7 @@ import {
Download,
FolderArchive,
Search,
FolderOpen,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -56,6 +57,7 @@ import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { useDisableCurrentOmo } from "@/lib/query/omo";
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
type View =
| "providers"
@@ -66,14 +68,15 @@ type View =
| "mcp"
| "agents"
| "universal"
| "sessions";
| "sessions"
| "workspace";
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
const getInitialApp = (): AppId => {
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
@@ -94,6 +97,7 @@ const VALID_VIEWS: View[] = [
"agents",
"universal",
"sessions",
"workspace",
];
const getInitialView = (): View => {
@@ -625,6 +629,8 @@ function App() {
case "sessions":
return <SessionManagerPage />;
case "workspace":
return <WorkspaceFilesPanel />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -783,6 +789,7 @@ function App() {
defaultValue: "统一供应商",
})}
{currentView === "sessions" && t("sessionManager.title")}
{currentView === "workspace" && t("workspace.title")}
</h1>
</div>
) : (
@@ -985,6 +992,17 @@ function App() {
>
<Book className="w-4 h-4" />
</Button>
{activeApp === "openclaw" && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -24,13 +24,14 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
};
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import MarkdownEditor from "@/components/MarkdownEditor";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { workspaceApi } from "@/lib/api/workspace";
interface WorkspaceFileEditorProps {
filename: string;
isOpen: boolean;
onClose: () => void;
}
const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
filename,
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isOpen || !filename) return;
setLoading(true);
workspaceApi
.readFile(filename)
.then((data) => {
setContent(data ?? "");
})
.catch((err) => {
console.error("Failed to read workspace file:", err);
toast.error(t("workspace.loadFailed"));
})
.finally(() => setLoading(false));
}, [isOpen, filename, t]);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await workspaceApi.writeFile(filename, content);
toast.success(t("workspace.saveSuccess"));
} catch (err) {
console.error("Failed to save workspace file:", err);
toast.error(t("workspace.saveFailed"));
} finally {
setSaving(false);
}
}, [filename, content, t]);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.editing", { filename })}
onClose={onClose}
footer={
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${filename}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
</FullScreenPanel>
);
};
export default WorkspaceFileEditor;

View File

@@ -0,0 +1,115 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
FileCode,
Heart,
User,
IdCard,
Wrench,
Brain,
CheckCircle2,
Circle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { workspaceApi } from "@/lib/api/workspace";
import WorkspaceFileEditor from "./WorkspaceFileEditor";
interface WorkspaceFile {
filename: string;
icon: LucideIcon;
descKey: string;
}
const WORKSPACE_FILES: WorkspaceFile[] = [
{ filename: "AGENTS.md", icon: FileCode, descKey: "workspace.files.agents" },
{ filename: "SOUL.md", icon: Heart, descKey: "workspace.files.soul" },
{ filename: "USER.md", icon: User, descKey: "workspace.files.user" },
{
filename: "IDENTITY.md",
icon: IdCard,
descKey: "workspace.files.identity",
},
{ filename: "TOOLS.md", icon: Wrench, descKey: "workspace.files.tools" },
{ filename: "MEMORY.md", icon: Brain, descKey: "workspace.files.memory" },
];
const WorkspaceFilesPanel: React.FC = () => {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState<string | null>(null);
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
const checkFileExistence = async () => {
const results: Record<string, boolean> = {};
await Promise.all(
WORKSPACE_FILES.map(async (f) => {
try {
const content = await workspaceApi.readFile(f.filename);
results[f.filename] = content !== null;
} catch {
results[f.filename] = false;
}
}),
);
setFileExists(results);
};
useEffect(() => {
void checkFileExistence();
}, []);
const handleEditorClose = () => {
setEditingFile(null);
// Re-check file existence after closing editor (file may have been created)
void checkFileExistence();
};
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
~/.openclaw/workspace/
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{WORKSPACE_FILES.map((file) => {
const Icon = file.icon;
const exists = fileExists[file.filename];
return (
<button
key={file.filename}
onClick={() => setEditingFile(file.filename)}
className="flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{file.filename}
</span>
{exists ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
) : (
<Circle className="w-3.5 h-3.5 text-muted-foreground/40 flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{t(file.descKey)}
</p>
</div>
</button>
);
})}
</div>
<WorkspaceFileEditor
filename={editingFile ?? ""}
isOpen={!!editingFile}
onClose={handleEditorClose}
/>
</div>
);
};
export default WorkspaceFilesPanel;

View File

@@ -1115,6 +1115,22 @@
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
}
},
"workspace": {
"title": "Workspace Files",
"manage": "Workspace",
"files": {
"agents": "Agent instructions and rules",
"soul": "Agent personality and communication style",
"user": "User profile and preferences",
"identity": "Agent name and avatar",
"tools": "Local tool documentation",
"memory": "Long-term memory and decisions"
},
"editing": "Edit {{filename}}",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"loadFailed": "Failed to load"
},
"env": {
"warning": {
"title": "Environment Variable Conflicts Detected",

View File

@@ -1115,6 +1115,22 @@
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"workspace": {
"title": "ワークスペースファイル",
"manage": "ワークスペース",
"files": {
"agents": "エージェントの操作指示とルール",
"soul": "エージェントの人格とコミュニケーションスタイル",
"user": "ユーザープロファイルと設定",
"identity": "エージェントの名前とアバター",
"tools": "ローカルツールドキュメント",
"memory": "長期記憶と意思決定記録"
},
"editing": "{{filename}} を編集",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"loadFailed": "読み込みに失敗しました"
},
"env": {
"warning": {
"title": "競合する環境変数を検出しました",

View File

@@ -1115,6 +1115,22 @@
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
}
},
"workspace": {
"title": "Workspace 文件管理",
"manage": "Workspace",
"files": {
"agents": "Agent 操作指令和规则",
"soul": "Agent 人格和沟通风格",
"user": "用户档案和偏好",
"identity": "Agent 名称和头像",
"tools": "本地工具文档",
"memory": "长期记忆和决策记录"
},
"editing": "编辑 {{filename}}",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"loadFailed": "读取失败"
},
"env": {
"warning": {
"title": "检测到系统环境变量冲突",

View File

@@ -9,6 +9,7 @@ export { vscodeApi } from "./vscode";
export { proxyApi } from "./proxy";
export { openclawApi } from "./openclaw";
export { sessionsApi } from "./sessions";
export { workspaceApi } from "./workspace";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";

11
src/lib/api/workspace.ts Normal file
View File

@@ -0,0 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
export const workspaceApi = {
async readFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_workspace_file", { filename });
},
async writeFile(filename: string, content: string): Promise<void> {
return invoke<void>("write_workspace_file", { filename, content });
},
};

View File

@@ -433,6 +433,7 @@ type = "stdio"
codex: false,
gemini: false,
opencode: false,
openclaw: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();