mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user