From 705cc8a5af9afc7ce5b45100fca92e934dc561a5 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 21:42:36 +0800 Subject: [PATCH] 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. --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/workspace.rs | 59 +++++++++ src-tauri/src/lib.rs | 5 + src/App.tsx | 22 +++- src/components/prompts/PromptFormPanel.tsx | 5 +- .../workspace/WorkspaceFileEditor.tsx | 95 +++++++++++++++ .../workspace/WorkspaceFilesPanel.tsx | 115 ++++++++++++++++++ src/i18n/locales/en.json | 16 +++ src/i18n/locales/ja.json | 16 +++ src/i18n/locales/zh.json | 16 +++ src/lib/api/index.ts | 1 + src/lib/api/workspace.ts | 11 ++ tests/components/McpFormModal.test.tsx | 1 + 13 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/commands/workspace.rs create mode 100644 src/components/workspace/WorkspaceFileEditor.tsx create mode 100644 src/components/workspace/WorkspaceFilesPanel.tsx create mode 100644 src/lib/api/workspace.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f7a1b81c..6a102b86 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs new file mode 100644 index 00000000..8e092de4 --- /dev/null +++ b/src-tauri/src/commands/workspace.rs @@ -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, 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}") + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 05cde967..26c2f4cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src/App.tsx b/src/App.tsx index a4af76b4..95c946c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; + case "workspace": + return ; default: return (
@@ -783,6 +789,7 @@ function App() { defaultValue: "统一供应商", })} {currentView === "sessions" && t("sessionManager.title")} + {currentView === "workspace" && t("workspace.title")}
) : ( @@ -985,6 +992,17 @@ function App() { > + {activeApp === "openclaw" && ( + + )} + } + > + {loading ? ( +
+ {t("prompts.loading")} +
+ ) : ( + + )} + + ); +}; + +export default WorkspaceFileEditor; diff --git a/src/components/workspace/WorkspaceFilesPanel.tsx b/src/components/workspace/WorkspaceFilesPanel.tsx new file mode 100644 index 00000000..04d151ca --- /dev/null +++ b/src/components/workspace/WorkspaceFilesPanel.tsx @@ -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(null); + const [fileExists, setFileExists] = useState>({}); + + const checkFileExistence = async () => { + const results: Record = {}; + 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 ( +
+

+ ~/.openclaw/workspace/ +

+ +
+ {WORKSPACE_FILES.map((file) => { + const Icon = file.icon; + const exists = fileExists[file.filename]; + + return ( + + ); + })} +
+ + +
+ ); +}; + +export default WorkspaceFilesPanel; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d9df8cbe..1dee7f48 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 02effd72..f2d753d3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1115,6 +1115,22 @@ "deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?" } }, + "workspace": { + "title": "ワークスペースファイル", + "manage": "ワークスペース", + "files": { + "agents": "エージェントの操作指示とルール", + "soul": "エージェントの人格とコミュニケーションスタイル", + "user": "ユーザープロファイルと設定", + "identity": "エージェントの名前とアバター", + "tools": "ローカルツールドキュメント", + "memory": "長期記憶と意思決定記録" + }, + "editing": "{{filename}} を編集", + "saveSuccess": "保存しました", + "saveFailed": "保存に失敗しました", + "loadFailed": "読み込みに失敗しました" + }, "env": { "warning": { "title": "競合する環境変数を検出しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c4012ac5..f0c2b08d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "检测到系统环境变量冲突", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 6a797f1e..7a0bfd1c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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"; diff --git a/src/lib/api/workspace.ts b/src/lib/api/workspace.ts new file mode 100644 index 00000000..09591348 --- /dev/null +++ b/src/lib/api/workspace.ts @@ -0,0 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const workspaceApi = { + async readFile(filename: string): Promise { + return invoke("read_workspace_file", { filename }); + }, + + async writeFile(filename: string, content: string): Promise { + return invoke("write_workspace_file", { filename, content }); + }, +}; diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index c7095118..8816ceaf 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -433,6 +433,7 @@ type = "stdio" codex: false, gemini: false, opencode: false, + openclaw: false, }); expect(onSave).toHaveBeenCalledTimes(1); expect(toastErrorMock).not.toHaveBeenCalled();