Newer
Older
cortex-hub / ui / client-app / src / pages / SkillsPage.js
import React, { useState, useEffect } from 'react';
import { getSkills, createSkill, updateSkill, deleteSkill } from '../services/apiService';

export default function SkillsPage({ user, Icon }) {
    const [skills, setSkills] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    const [isModalOpen, setIsModalOpen] = useState(false);
    const [editingSkill, setEditingSkill] = useState(null);

    const [formData, setFormData] = useState({
        name: '',
        description: '',
        skill_type: 'local',
        config: '{}',
        is_system: false,
        system_prompt: '',
        is_enabled: true,
        features: ['chat']
    });

    const fetchSkills = async () => {
        try {
            setLoading(true);
            const data = await getSkills();
            setSkills(data);
            setError(null);
        } catch (err) {
            setError("Failed to load skills.");
        } finally {
            setLoading(false);
        }
    };

    useEffect(() => {
        fetchSkills();
    }, []);

    const openModal = (skill = null) => {
        if (skill) {
            setEditingSkill(skill);
            setFormData({
                name: skill.name,
                description: skill.description || '',
                skill_type: skill.skill_type,
                config: JSON.stringify(skill.config, null, 2),
                is_system: skill.is_system,
                system_prompt: skill.system_prompt || '',
                is_enabled: skill.is_enabled ?? true,
                features: skill.features || ['chat']
            });
        } else {
            setEditingSkill(null);
            setFormData({
                name: '',
                description: '',
                skill_type: 'local',
                config: '{}',
                is_system: false,
                system_prompt: '',
                is_enabled: true,
                features: ['chat']
            });
        }
        setIsModalOpen(true);
    };

    const handleClone = (skill) => {
        setEditingSkill(null); // Force it to act like a 'Create'
        setFormData({
            name: `${skill.name}_clone`,
            description: skill.description || '',
            skill_type: skill.skill_type,
            config: JSON.stringify(skill.config, null, 2),
            is_system: false,
            system_prompt: skill.system_prompt || '',
            is_enabled: true,
            features: skill.features || ['chat']
        });
        setIsModalOpen(true);
    };

    const closeModal = () => {
        setIsModalOpen(false);
        setEditingSkill(null);
    };

    const handleSave = async () => {
        try {
            let configObj = {};
            try {
                configObj = JSON.parse(formData.config);
            } catch (e) {
                alert("Invalid JSON in config");
                return;
            }

            const payload = {
                name: formData.name,
                description: formData.description,
                skill_type: formData.skill_type,
                config: configObj,
                is_system: formData.is_system,
                system_prompt: formData.system_prompt,
                is_enabled: formData.is_enabled,
                features: formData.features
            };

            if (editingSkill) {
                await updateSkill(editingSkill.id, payload);
            } else {
                await createSkill(payload);
            }
            closeModal();
            fetchSkills();
        } catch (err) {
            alert("Error saving skill");
        }
    };

    const handleDelete = async (id) => {
        if (!window.confirm("Are you sure you want to delete this skill?")) return;
        try {
            await deleteSkill(id);
            fetchSkills();
        } catch (err) {
            alert("Error deleting skill");
        }
    };

    const isAdmin = user?.role === 'admin';

    return (
        <div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 overflow-hidden">
            <div className="flex-none p-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm flex justify-between items-center z-10">
                <div>
                    <h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-purple-600">
                        Skills & Workflows
                    </h1>
                    <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
                        Create, manage, and share AI capabilities and workflows.
                    </p>
                </div>
                <button
                    onClick={() => openModal()}
                    className="flex items-center space-x-2 bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow-sm"
                >
                    <Icon path="M12 5v14m-7-7h14" className="w-5 h-5" />
                    <span>Create Skill</span>
                </button>
            </div>

            <div className="flex-grow p-6 overflow-y-auto">
                {loading ? (
                    <div className="flex justify-center items-center h-64">
                        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
                    </div>
                ) : error ? (
                    <div className="p-4 bg-red-100 text-red-700 rounded-lg">{error}</div>
                ) : (
                    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                        {skills.map((skill) => (
                            <div key={skill.id} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 shadow-sm hover:shadow-md transition-shadow flex flex-col">
                                <div className="flex justify-between items-start mb-2">
                                    <h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
                                        {skill.name}
                                        {skill.is_system && (
                                            <span className="bg-yellow-100 text-yellow-800 text-xs px-2 py-0.5 rounded border border-yellow-200">System</span>
                                        )}
                                        {skill.group_id && !skill.is_system && (
                                            <span className="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded border border-blue-200">Group</span>
                                        )}
                                        {!skill.is_enabled && (
                                            <span className="bg-red-100 text-red-800 text-xs px-2 py-0.5 rounded border border-red-200">Disabled</span>
                                        )}
                                    </h3>
                                    <div className="flex items-center space-x-2 text-gray-400">
                                        <button onClick={() => handleClone(skill)} className="hover:text-green-500 transition-colors" title="Clone Skill">
                                            <Icon path="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" className="w-5 h-5" />
                                        </button>
                                        {(isAdmin || skill.owner_id === user?.id) && (
                                            <>
                                                <button onClick={() => openModal(skill)} className="hover:text-indigo-500 transition-colors" title="Edit Skill">
                                                    <Icon path="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" className="w-5 h-5" />
                                                </button>
                                                <button onClick={() => handleDelete(skill.id)} className="hover:text-red-500 transition-colors" title="Delete Skill">
                                                    <Icon path="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" className="w-5 h-5" />
                                                </button>
                                            </>
                                        )}
                                    </div>
                                </div>

                                <p className="text-gray-600 dark:text-gray-400 text-sm flex-grow mb-4">
                                    {skill.description || "No description provided."}
                                </p>

                                <div className="flex flex-wrap gap-1 mb-4">
                                    {(skill.features || []).map(f => (
                                        <span key={f} className="bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded border border-indigo-100 dark:border-indigo-800">
                                            {f}
                                        </span>
                                    ))}
                                </div>

                                <div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700 flex justify-between items-center text-xs text-gray-500">
                                    <span className="uppercase tracking-wider font-semibold bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
                                        TYPE: {skill.skill_type}
                                    </span>
                                    <span>Created: {new Date(skill.created_at).toLocaleDateString()}</span>
                                </div>
                            </div>
                        ))}
                        {skills.length === 0 && (
                            <div className="col-span-full p-8 text-center text-gray-500 bg-white dark:bg-gray-800 rounded-xl border border-dashed border-gray-300 dark:border-gray-700">
                                <Icon path="M12 2l-1 4h-4l3 3-1 4 3-2 3 2-1-4 3-3h-4z" className="w-12 h-12 mx-auto mb-3 text-gray-400" />
                                <p className="text-lg">No skills found.</p>
                                <p className="text-sm mt-1">Create your first skill to extend the AI's capabilities.</p>
                            </div>
                        )}
                    </div>
                )}
            </div>

            {isModalOpen && (
                <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
                    <div className="bg-white dark:bg-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden border border-gray-200 dark:border-gray-700">
                        <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-800/50">
                            <h2 className="text-xl font-bold flex items-center gap-2">
                                <Icon path="M12 2l-1 4h-4l3 3-1 4 3-2 3 2-1-4 3-3h-4z" className="w-6 h-6 text-indigo-500" />
                                {editingSkill ? 'Edit Skill Configuration' : 'Create New Skill'}
                            </h2>
                            <button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
                                <Icon path="M6 18L18 6M6 6l12 12" className="w-6 h-6" />
                            </button>
                        </div>

                        <div className="p-6 overflow-y-auto space-y-6 flex-grow custom-scrollbar">
                            <div className="grid grid-cols-2 gap-4">
                                <div className="col-span-2 md:col-span-1">
                                    <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Skill Name</label>
                                    <input
                                        type="text"
                                        value={formData.name}
                                        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
                                        className="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder-gray-400"
                                        placeholder="e.g. github_search"
                                    />
                                </div>
                                <div className="col-span-2 md:col-span-1">
                                    <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Skill Type</label>
                                    <select
                                        value={formData.skill_type}
                                        onChange={(e) => setFormData({ ...formData, skill_type: e.target.value })}
                                        className="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
                                    >
                                        <option value="local">Local Native</option>
                                        <option value="mcp">MCP Protocol</option>
                                        <option value="remote_grpc">Remote Node (gRPC)</option>
                                    </select>
                                    <p className="mt-2 text-xs text-gray-400">
                                        {formData.skill_type === 'local' && "Runs directly on the Cortex Hub server."}
                                        {formData.skill_type === 'mcp' && "Connects to an external Model Context Protocol server."}
                                        {formData.skill_type === 'remote_grpc' && "Dispatches commands to an attached Agent Node via gRPC."}
                                    </p>
                                </div>
                            </div>

                            <div>
                                <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Description / Goal</label>
                                <textarea
                                    value={formData.description}
                                    onChange={(e) => setFormData({ ...formData, description: e.target.value })}
                                    className="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all min-h-[100px] resize-y placeholder-gray-400"
                                    placeholder="Describe what this capability does..."
                                />
                            </div>

                            <div>
                                <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Configuration Payload (JSON)</label>
                                <textarea
                                    value={formData.config}
                                    onChange={(e) => setFormData({ ...formData, config: e.target.value })}
                                    className="w-full bg-gray-900 text-green-400 font-mono text-sm border border-gray-700 rounded-lg px-4 py-3 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all min-h-[150px] resize-y"
                                />
                            </div>

                            <div className="grid grid-cols-2 gap-4">
                                <div>
                                    <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">System Prompt</label>
                                    <textarea
                                        value={formData.system_prompt}
                                        onChange={(e) => setFormData({ ...formData, system_prompt: e.target.value })}
                                        className="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 outline-none min-h-[80px] text-sm"
                                        placeholder="System instructions for the AI..."
                                    />
                                </div>
                                <div>
                                    <label className="block text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Classification (Features)</label>
                                    <div className="flex flex-wrap gap-3 mt-2">
                                        {['chat', 'voice', 'workflow'].map(f => (
                                            <label key={f} className="flex items-center gap-2 cursor-pointer">
                                                <input
                                                    type="checkbox"
                                                    checked={formData.features.includes(f)}
                                                    onChange={(e) => {
                                                        const newFeatures = e.target.checked
                                                            ? [...formData.features, f]
                                                            : formData.features.filter(x => x !== f);
                                                        setFormData({ ...formData, features: newFeatures });
                                                    }}
                                                    className="w-4 h-4 text-indigo-600 rounded"
                                                />
                                                <span className="capitalize text-sm">{f}</span>
                                            </label>
                                        ))}
                                    </div>
                                    <div className="flex items-center mt-6">
                                        <input
                                            type="checkbox"
                                            id="isEnabled"
                                            checked={formData.is_enabled}
                                            onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
                                            className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
                                        />
                                        <label htmlFor="isEnabled" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
                                            Skill is Enabled (Visible to users)
                                        </label>
                                    </div>
                                </div>

                                {isAdmin && (
                                    <div className="flex items-center mt-2 col-span-2 border-t border-gray-100 dark:border-gray-700 pt-4">
                                        <input
                                            type="checkbox"
                                            id="isSystem"
                                            checked={formData.is_system}
                                            onChange={(e) => setFormData({ ...formData, is_system: e.target.checked })}
                                            className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
                                        />
                                        <label htmlFor="isSystem" className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
                                            Mark as Core System Skill (Global for all users)
                                        </label>
                                    </div>
                                )}
                            </div>
                        </div>

                        <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 flex justify-end space-x-3">
                            <button
                                onClick={closeModal}
                                className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
                            >
                                Cancel
                            </button>
                            <button
                                onClick={handleSave}
                                className="px-6 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg shadow transition-colors flex items-center gap-2"
                            >
                                <Icon path="M5 13l4 4L19 7" className="w-4 h-4" />
                                {editingSkill ? 'Save Changes' : 'Create Skill'}
                            </button>
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
}