From ed5cfd2102833fadc17fcc7f98e7c9f030230fb6 Mon Sep 17 00:00:00 2001 From: zdj1231 Date: Wed, 10 Jun 2026 10:21:35 +0800 Subject: [PATCH 1/2] fix: parameter type of read_execution_trajectory, and conductivity in mattersim --- .../skills/mattersim/assets/conductivity.md | 338 +++++++++++++----- 1 file changed, 248 insertions(+), 90 deletions(-) diff --git a/src/matcreator/skills/mattersim/assets/conductivity.md b/src/matcreator/skills/mattersim/assets/conductivity.md index fda79341..9ea4f0f0 100644 --- a/src/matcreator/skills/mattersim/assets/conductivity.md +++ b/src/matcreator/skills/mattersim/assets/conductivity.md @@ -1,107 +1,265 @@ -# Example python code to plot msd curve, compute diffusivity and conductivity -Recommended workflow: -1. Read the MD trajectory file. -2. Select the target mobile ion species whose transport behavior should be analyzed, such as `Li`. -3. Discard an initial equilibration portion of the trajectory before analysis. -4. Convert the selected ASE frames to `pymatgen Structure` objects. -5. Use `pymatgen.analysis.diffusion.analyzer.DiffusionAnalyzer` to compute: - - the MSD curve - - the tracer diffusivity - - the ionic conductivity - -Example: +# Example python code for MSD, diffusivity, and conductivity analysis with a manual MSD workflow ```python +from pathlib import Path +import os + import numpy as np -import matplotlib.pyplot as plt from ase.io import read -from pymatgen.io.ase import AseAtomsAdaptor -from pymatgen.analysis.diffusion.analyzer import DiffusionAnalyzer -trajectory_path = "300.0_nvt.traj" -frames = read(trajectory_path, index=":") + +SCRIPT_DIR = Path(__file__).resolve().parent +os.environ.setdefault("MPLCONFIGDIR", str(SCRIPT_DIR / ".matplotlib")) + +import matplotlib.pyplot as plt + + +STRUCTURE_LABELS = ["0", "25", "50", "75", "100"] +TRAJECTORY_NAME = "300.0_nvt.traj" mobile_species = "Li" +mobile_ion_charge = 1.0 temperature_K = 300.0 time_step_fs = 2.0 step_skip = 100 -analysis_start_fraction = 0.40 -analysis_end_fraction = 0.80 - -n_total = len(frames) -if n_total < 10: - raise ValueError(f"Trajectory frames too few: {n_total}") - -# Analyze only the production-like portion of the trajectory to reduce -# equilibration bias and noisy tail effects in the MSD/conductivity estimate. -start_index = int(n_total * analysis_start_fraction) -end_index = int(n_total * analysis_end_fraction) -selected_frames = frames[start_index:end_index] - -symbols = selected_frames[0].get_chemical_symbols() -n_mobile = symbols.count(mobile_species) -if n_mobile == 0: - raise ValueError(f"No {mobile_species!r} atoms found in structure.") - -adaptor = AseAtomsAdaptor() -structures = [adaptor.get_structure(atoms) for atoms in selected_frames] - -analyzer = DiffusionAnalyzer.from_structures( - structures=structures, - specie=mobile_species, - temperature=temperature_K, - time_step=time_step_fs, - step_skip=step_skip, - smoothed=False, -) - -print(f"Diffusivity: {analyzer.diffusivity:.6e} cm^2/s") -print(f"Diffusivity std dev: {analyzer.diffusivity_std_dev:.6e} cm^2/s") -print(f"Conductivity: {analyzer.conductivity:.6e} mS/cm") -print(f"Conductivity std dev: {analyzer.conductivity_std_dev:.6e} mS/cm") - -frame_interval_fs = time_step_fs * step_skip -dt_ps = frame_interval_fs / 1000.0 -start_time_ps = start_index * dt_ps -relative_time_ps = np.arange(len(analyzer.msd)) * dt_ps -absolute_time_ps = start_time_ps + relative_time_ps - -np.savetxt( - "li_msd_selected_window.dat", - np.column_stack([relative_time_ps, absolute_time_ps, analyzer.msd]), - header="time_ps_relative time_ps_absolute msd_A2", -) - -plt.figure(figsize=(6, 4)) -plt.plot(relative_time_ps, analyzer.msd, linewidth=2) -plt.xlabel("Time / ps") -plt.ylabel(r"MSD / $\\AA^2$") -plt.title( - f"{mobile_species} MSD from selected trajectory window\\n" - f"Absolute window: {start_time_ps:.3f} to {absolute_time_ps[-1]:.3f} ps\\n" - f"Diffusivity: {analyzer.diffusivity:.6e} cm^2/s\\n" - f"Conductivity: {analyzer.conductivity:.6e} mS/cm" -) -plt.tight_layout() -plt.savefig("li_msd_selected_window.png", dpi=300) -``` +analysis_start_fraction = 0.0 +analysis_end_fraction = 1.0 +msd_fit_start_fraction = 0.5 +msd_fit_end_fraction = 1.0 + +ELEMENTARY_CHARGE_C = 1.602176634e-19 +BOLTZMANN_J_K = 1.380649e-23 + + +def analyze_trajectory(label): + trajectory_path = SCRIPT_DIR / label / TRAJECTORY_NAME + if not trajectory_path.exists(): + raise FileNotFoundError(f"Trajectory not found: {trajectory_path}") + + frames = read(trajectory_path, index=":") + n_total = len(frames) + if n_total < 10: + raise ValueError(f"Trajectory frames too few for {label}: {n_total}") + + start_index = int(n_total * analysis_start_fraction) + end_index = int(n_total * analysis_end_fraction) + selected_frames = frames[start_index:end_index] + if len(selected_frames) < 10: + raise ValueError( + f"Selected trajectory frames too few for {label}: {len(selected_frames)}" + ) + + symbols = selected_frames[0].get_chemical_symbols() + mobile_indices = [i for i, symbol in enumerate(symbols) if symbol == mobile_species] + n_mobile = len(mobile_indices) + if n_mobile == 0: + raise ValueError(f"No {mobile_species!r} atoms found in {label}.") + + frame_interval_fs = time_step_fs * step_skip + dt_ps = frame_interval_fs / 1000.0 + start_time_ps = start_index * dt_ps + relative_time_ps = np.arange(len(selected_frames)) * dt_ps + absolute_time_ps = start_time_ps + relative_time_ps + msd = calculate_msd(selected_frames, mobile_indices) + diffusivity, diffusivity_std_dev, slope = calculate_diffusivity( + relative_time_ps, msd + ) + conductivity = calculate_conductivity(selected_frames, n_mobile, diffusivity) + conductivity_std_dev = calculate_conductivity( + selected_frames, n_mobile, diffusivity_std_dev + ) + + msd_data_path = SCRIPT_DIR / f"{mobile_species.lower()}_msd_{label}.dat" + np.savetxt( + msd_data_path, + np.column_stack([relative_time_ps, absolute_time_ps, msd]), + header="time_ps_relative time_ps_absolute msd_A2", + ) + + return { + "label": label, + "n_frames": n_total, + "n_selected_frames": len(selected_frames), + "n_mobile": n_mobile, + "start_time_ps": start_time_ps, + "end_time_ps": absolute_time_ps[-1], + "time_ps": relative_time_ps, + "msd": msd, + "msd_slope": slope, + "diffusivity": diffusivity, + "diffusivity_std_dev": diffusivity_std_dev, + "conductivity": conductivity, + "conductivity_std_dev": conductivity_std_dev, + "msd_data_path": msd_data_path, + } + + +def calculate_msd(frames, mobile_indices): + mobile_indices = np.array(mobile_indices) + n_frames = len(frames) + first = frames[0] + previous_scaled = first.get_scaled_positions(wrap=True)[mobile_indices] + unwrapped_positions = np.empty((n_frames, len(mobile_indices), 3)) + unwrapped_positions[0] = first.get_positions()[mobile_indices] + + for i, atoms in enumerate(frames[1:], start=1): + scaled = atoms.get_scaled_positions(wrap=True)[mobile_indices] + delta_scaled = scaled - previous_scaled + delta_scaled -= np.round(delta_scaled) + delta_cart = np.dot(delta_scaled, atoms.get_cell().array) + unwrapped_positions[i] = unwrapped_positions[i - 1] + delta_cart + previous_scaled = scaled + + displacements = unwrapped_positions - unwrapped_positions[0] + squared_displacements = np.sum(displacements**2, axis=2) + return np.mean(squared_displacements, axis=1) -Important analysis parameters -- `trajectory_path`: NVT trajectory path, for example `300.0_nvt.traj` -- `mobile_species`: mobile ion species such as `Li` -- `temperature_K`: simulation temperature in Kelvin; keep this consistent with the MD run -- `time_step_fs`: MD timestep in fs; for the current `mattersim_moldyn.py`, this comes from `--timestep` -- `step_skip`: frame stride in MD steps between saved trajectory frames; for the current script this is `dumpfreq = 100` -- `analysis_start_fraction` and `analysis_end_fraction`: frame selection window for the MSD analysis; the current example uses the 40% to 80% portion of the trajectory +def calculate_diffusivity(time_ps, msd): + fit_start = int(len(msd) * msd_fit_start_fraction) + fit_end = int(len(msd) * msd_fit_end_fraction) + fit_start = max(0, min(fit_start, len(msd) - 2)) + fit_end = max(fit_start + 2, min(fit_end, len(msd))) -- because `DiffusionAnalyzer` uses the first frame of the selected window as the displacement reference, the MSD plot should usually use a relative time axis starting from `0 ps`; if needed, also record the absolute start time of that selected window in the original trajectory + fit_time = time_ps[fit_start:fit_end] + fit_msd = msd[fit_start:fit_end] + coeffs, covariance = np.polyfit(fit_time, fit_msd, 1, cov=True) + slope = coeffs[0] + slope_std_dev = np.sqrt(covariance[0, 0]) + diffusivity_cm2_s = slope * 1.0e-4 / 6.0 + diffusivity_std_dev_cm2_s = slope_std_dev * 1.0e-4 / 6.0 + return diffusivity_cm2_s, diffusivity_std_dev_cm2_s, slope + + +def calculate_conductivity(frames, n_mobile, diffusivity_cm2_s): + volumes_a3 = np.array([atoms.get_volume() for atoms in frames]) + mean_volume_m3 = np.mean(volumes_a3) * 1.0e-30 + number_density_m3 = n_mobile / mean_volume_m3 + diffusivity_m2_s = diffusivity_cm2_s * 1.0e-4 + + conductivity_s_m = ( + number_density_m3 + * (mobile_ion_charge * ELEMENTARY_CHARGE_C) ** 2 + * diffusivity_m2_s + / (BOLTZMANN_J_K * temperature_K) + ) + return conductivity_s_m * 10.0 + + +def save_summary(results): + summary_path = SCRIPT_DIR / f"{mobile_species.lower()}_diffusion_summary.dat" + header = ( + "label n_frames n_selected_frames n_mobile start_time_ps end_time_ps " + "diffusivity_cm2_s diffusivity_std_dev_cm2_s " + "conductivity_mS_cm conductivity_std_dev_mS_cm" + ) + with summary_path.open("w", encoding="utf-8") as file: + file.write(f"# {header}\n") + for result in results: + file.write( + f"{result['label']} " + f"{result['n_frames']:d} " + f"{result['n_selected_frames']:d} " + f"{result['n_mobile']:d} " + f"{result['start_time_ps']:.8f} " + f"{result['end_time_ps']:.8f} " + f"{result['diffusivity']:.8e} " + f"{result['diffusivity_std_dev']:.8e} " + f"{result['conductivity']:.8e} " + f"{result['conductivity_std_dev']:.8e}\n" + ) + return summary_path + + +def plot_msd(results): + plt.figure(figsize=(7, 4.5)) + for result in results: + plt.plot( + result["time_ps"], + result["msd"], + linewidth=2, + label=f"{result['label']}", + ) + + plt.xlabel("Time / ps") + plt.ylabel(r"MSD / $\mathrm{\AA}^2$") + plt.title(f"{mobile_species} MSD comparison") + plt.legend(title="Structure") + plt.tight_layout() + + plot_path = SCRIPT_DIR / f"{mobile_species.lower()}_msd_comparison.png" + plt.savefig(plot_path, dpi=300) + plt.close() + return plot_path + + +def plot_conductivity(results): + labels = [result["label"] for result in results] + conductivities = [result["conductivity"] for result in results] + conductivity_std_devs = [result["conductivity_std_dev"] for result in results] + + plt.figure(figsize=(7, 4.5)) + x = np.arange(len(labels)) + plt.bar( + x, + conductivities, + yerr=conductivity_std_devs, + capsize=5, + color="#4C78A8", + edgecolor="black", + linewidth=0.8, + ) + plt.xticks(x, labels) + plt.xlabel("Structure") + plt.ylabel("Conductivity / mS cm$^{-1}$") + plt.title(f"{mobile_species} conductivity comparison") + plt.tight_layout() + + plot_path = SCRIPT_DIR / f"{mobile_species.lower()}_conductivity_comparison.png" + plt.savefig(plot_path, dpi=300) + plt.close() + return plot_path + + +def main(): + results = [analyze_trajectory(label) for label in STRUCTURE_LABELS] + + for result in results: + print(f"Structure {result['label']}") + print( + f" Window: {result['start_time_ps']:.3f} to " + f"{result['end_time_ps']:.3f} ps" + ) + print(f" Diffusivity: {result['diffusivity']:.6e} cm^2/s") + print( + f" Diffusivity std dev: " + f"{result['diffusivity_std_dev']:.6e} cm^2/s" + ) + print(f" Conductivity: {result['conductivity']:.6e} mS/cm") + print( + f" Conductivity std dev: " + f"{result['conductivity_std_dev']:.6e} mS/cm" + ) + + summary_path = save_summary(results) + msd_plot_path = plot_msd(results) + conductivity_plot_path = plot_conductivity(results) + + print(f"Saved summary: {summary_path}") + print(f"Saved MSD comparison plot: {msd_plot_path}") + print(f"Saved conductivity comparison plot: {conductivity_plot_path}") + + +if __name__ == "__main__": + main() +``` -## Notes +Parameter notes: -- Use the same `mobile_species`, `temperature_K`, `time_step_fs`, and `step_skip` that correspond to the actual MD setup. -- Be explicit about which trajectory segment is analyzed; excluding early equilibration frames is usually important for stable results. -- The current example saves both relative and absolute time columns in the MSD data file. Use the relative time axis for the MSD curve itself, and the absolute time column to map the selected window back to the original trajectory. -- If the trajectory is too short or too noisy, report that the estimated conductivity has limited confidence. \ No newline at end of file +- `time_step_fs` and `step_skip` must match the actual MD integration step and trajectory write interval. +- `analysis_start_fraction` / `analysis_end_fraction` define which part of the trajectory is analyzed. +- `msd_fit_start_fraction` / `msd_fit_end_fraction` define the MSD fitting window and can strongly affect the extracted diffusivity and conductivity. +- `mobile_species` selects the diffusing ion species. +- `mobile_ion_charge` and `temperature_K` are used in the Nernst-Einstein conductivity conversion. \ No newline at end of file From 65d749f0802b231011341f962bb79d5322263547 Mon Sep 17 00:00:00 2001 From: zdj1231 Date: Thu, 16 Jul 2026 22:21:36 +0800 Subject: [PATCH 2/2] fix: custom skill in server mode --- tests/test_web_session_access.py | 66 ++++++++- web/main.py | 130 ++++++++++++++---- .../features/settings/SettingsController.js | 4 +- 3 files changed, 171 insertions(+), 29 deletions(-) diff --git a/tests/test_web_session_access.py b/tests/test_web_session_access.py index 5e5a19bb..6015bcb6 100644 --- a/tests/test_web_session_access.py +++ b/tests/test_web_session_access.py @@ -32,6 +32,18 @@ def __init__(self, image_id: str): self.images = _FakeImages(image_id) +class _UploadFile: + def __init__(self, content: bytes, filename: str): + self._content = content + self.filename = filename + + async def read(self) -> bytes: + return self._content + + async def close(self) -> None: + return None + + def _load_web_main(monkeypatch, matcreator_home: Path | None = None): root = Path(__file__).resolve().parents[1] monkeypatch.setenv("MATCREATOR_MODE", "local") @@ -206,6 +218,58 @@ def test_server_env_config_writes_worker_mounted_user_config(monkeypatch, tmp_pa assert "FRONTEND_SET_FLAG: visible-to-worker" in container_config.read_text(encoding="utf-8") +def test_server_custom_skill_upload_writes_user_worker_mount_and_restarts(monkeypatch, tmp_path): + control_home = tmp_path / "control-plane" / ".matcreator" + control_home.mkdir(parents=True) + data_root = tmp_path / "container-data" + host_data_root = tmp_path / "host-data" + web_main = _load_web_main_server(monkeypatch, control_home, data_root, host_data_root) + restarted = [] + + async def run_inline(func, *args): + return func(*args) + + monkeypatch.setattr(web_main.asyncio, "to_thread", run_inline) + monkeypatch.setattr(web_main, "remove_worker", lambda user_id: restarted.append(("remove", user_id))) + monkeypatch.setattr(web_main, "ensure_worker_running", lambda user_id: restarted.append(("start", user_id))) + + skill_md = b"---\nname: demo-skill\ndescription: Demo skill\n---\n\n# Demo\n" + response = asyncio.run(web_main.create_custom_skill( + name="demo-skill", + skill_md=_UploadFile(skill_md, "SKILL.md"), + references=[], + scripts=[], + user_id="alice", + )) + + assert response.status_code == 200 + assert restarted == [("remove", "alice"), ("start", "alice")] + assert (host_data_root / "users" / "alice" / ".matcreator" / "workspace" / "skills" / "demo-skill" / "SKILL.md").read_bytes() == skill_md + assert (data_root / "users" / "alice" / ".matcreator" / "workspace" / "skills" / "demo-skill" / "SKILL.md").read_bytes() == skill_md + assert not (control_home / "workspace" / "skills" / "demo-skill").exists() + + +def test_server_custom_skill_list_is_scoped_to_user(monkeypatch, tmp_path): + control_home = tmp_path / "control-plane" / ".matcreator" + control_home.mkdir(parents=True) + data_root = tmp_path / "container-data" + host_data_root = tmp_path / "host-data" + web_main = _load_web_main_server(monkeypatch, control_home, data_root, host_data_root) + alice_skill = data_root / "users" / "alice" / ".matcreator" / "workspace" / "skills" / "alice-skill" + bob_skill = data_root / "users" / "bob" / ".matcreator" / "workspace" / "skills" / "bob-skill" + alice_skill.mkdir(parents=True) + bob_skill.mkdir(parents=True) + (alice_skill / "SKILL.md").write_text("---\nname: alice-skill\ndescription: Alice only\n---\n\n# Alice\n", encoding="utf-8") + (bob_skill / "SKILL.md").write_text("---\nname: bob-skill\ndescription: Bob only\n---\n\n# Bob\n", encoding="utf-8") + + response = asyncio.run(web_main.list_skills(user_id="alice")) + skills = json.loads(response.body) + names = {skill["name"] for skill in skills} + + assert "alice-skill" in names + assert "bob-skill" not in names + + def test_server_session_summaries_are_scoped_to_user_home(monkeypatch, tmp_path): control_home = tmp_path / "control-plane" / ".matcreator" control_home.mkdir(parents=True) @@ -296,4 +360,4 @@ def test_local_mode_reads_session_detail_regardless_of_requested_user(monkeypatc assert payload["userId"] == "legacy-display-name" assert payload["state"] == {"answer": 42} - assert payload["events"] == [{"event": "persisted"}] \ No newline at end of file + assert payload["events"] == [{"event": "persisted"}] diff --git a/web/main.py b/web/main.py index efff6179..0fe69506 100644 --- a/web/main.py +++ b/web/main.py @@ -359,6 +359,29 @@ def _user_workspace_root(user_id: str) -> Path: return _user_matcreator_home(user_id) / "workspace" +def _custom_skill_roots(user_id: str = "") -> list[Path]: + """Return the workspace skill roots that belong to one user. + + In server mode the control plane may see its data directory at a different + path from Docker. Keep both locations in sync: workers mount the host + location, while the control plane reads the container location. + """ + if _MATCREATOR_MODE != "server": + return [workspace_skills_dir()] + if not user_id: + raise HTTPException(status_code=400, detail="user_id required in server mode") + + roots = [ + _user_workspace_root(user_id) / "skills", + _user_matcreator_home(user_id, host=True) / "workspace" / "skills", + ] + unique_roots: list[Path] = [] + for root in roots: + if root not in unique_roots: + unique_roots.append(root) + return unique_roots + + def _worker_target_url(user_id: str, port: int | None = None) -> str: if _WORKER_CONNECT_MODE == "host-port": if port is None: @@ -3121,10 +3144,12 @@ async def delete_skill_graph_attachment(skill_name: str, path: str = Query(...)) @app.get("/api/skills") async def list_skills(user_id: str = Query(default="")) -> JSONResponse: """Return all loaded skills with their planning_enabled status and parent skill (if any).""" + from google.adk.skills import load_skill_from_dir # noqa: PLC0415 from matcreator.skill import _MODULE_SKILLS_ROOT, _discover_skill_dirs # noqa: PLC0415 parent_map: dict[str, str] = {} - for root in [_MODULE_SKILLS_ROOT, official_skills_dir(), workspace_skills_dir()]: + workspace_roots = _custom_skill_roots(user_id) + for root in [_MODULE_SKILLS_ROOT, official_skills_dir(), *workspace_roots]: for path in _discover_skill_dirs(root): parent_skill_md = path.parent / "SKILL.md" if parent_skill_md.is_file(): @@ -3135,8 +3160,12 @@ async def list_skills(user_id: str = Query(default="")) -> JSONResponse: planning_skills = set((config.get("planning") or {}).get("extra_skills") or []) disabled_skills = set((config.get("skills") or {}).get("disabled") or []) skills = [] + seen_names: set[str] = set() for s in sorted(ALL_SKILLS, key=lambda s: s.name): source = get_skill_source(s.name) + if _MATCREATOR_MODE == "server" and source and source.name in {"custom", "workspace"}: + continue + seen_names.add(s.name) skills.append({ "name": s.name, "description": s.description or "", @@ -3149,6 +3178,31 @@ async def list_skills(user_id: str = Query(default="")) -> JSONResponse: "trusted": bool(source and source.trusted), "is_custom": bool(source and source.name in {"custom", "workspace"}), }) + + if _MATCREATOR_MODE == "server": + for root in workspace_roots: + for path in _discover_skill_dirs(root): + if path.name in seen_names: + continue + try: + skill = load_skill_from_dir(path) + except Exception as exc: + logger.warning("Failed to load custom skill '%s' for user %s: %s", path.name, user_id, exc) + continue + seen_names.add(skill.name) + skills.append({ + "name": skill.name, + "description": skill.description or "", + "planning_enabled": skill.name in planning_skills, + "enabled": skill.name not in disabled_skills, + "parent": parent_map.get(skill.name), + "source": "workspace", + "editable": True, + "managed": False, + "trusted": False, + "is_custom": True, + }) + skills.sort(key=lambda skill: skill["name"]) return JSONResponse(skills) @@ -3187,6 +3241,7 @@ async def create_custom_skill( skill_md: UploadFile = File(...), references: List[UploadFile] = File(default=[]), scripts: List[UploadFile] = File(default=[]), + user_id: str = Query(default=""), ) -> JSONResponse: """Upload a custom skill to the workspace skills directory.""" name = name.strip() @@ -3201,8 +3256,9 @@ async def create_custom_skill( detail=f"'{name}' is a built-in default skill. Custom skills cannot use the same name.", ) - skill_dir = workspace_skills_dir() / name - skill_dir.mkdir(parents=True, exist_ok=True) + skill_dirs = [root / name for root in _custom_skill_roots(user_id)] + for skill_dir in skill_dirs: + skill_dir.mkdir(parents=True, exist_ok=True) try: skill_md_content = await skill_md.read() @@ -3210,26 +3266,31 @@ async def create_custom_skill( _validate_skill_md_name(skill_md_content, name) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) - (skill_dir / "SKILL.md").write_bytes(skill_md_content) + for skill_dir in skill_dirs: + (skill_dir / "SKILL.md").write_bytes(skill_md_content) ref_names = [] non_empty_refs = [r for r in references if r.filename] if non_empty_refs: - ref_dir = skill_dir / "references" - ref_dir.mkdir(exist_ok=True) for ref_file in non_empty_refs: safe_name = _safe_upload_filename(ref_file.filename or "ref") - (ref_dir / safe_name).write_bytes(await ref_file.read()) + content = await ref_file.read() + for skill_dir in skill_dirs: + ref_dir = skill_dir / "references" + ref_dir.mkdir(exist_ok=True) + (ref_dir / safe_name).write_bytes(content) ref_names.append(safe_name) script_names = [] non_empty_scripts = [s for s in scripts if s.filename] if non_empty_scripts: - scripts_dir = skill_dir / "scripts" - scripts_dir.mkdir(exist_ok=True) for script_file in non_empty_scripts: safe_name = _safe_upload_filename(script_file.filename or "script") - (scripts_dir / safe_name).write_bytes(await script_file.read()) + content = await script_file.read() + for skill_dir in skill_dirs: + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir(exist_ok=True) + (scripts_dir / safe_name).write_bytes(content) script_names.append(safe_name) except OSError as exc: raise HTTPException(status_code=500, detail=f"Failed to write skill files: {exc}") @@ -3240,16 +3301,23 @@ async def create_custom_skill( for s in scripts: await s.close() - try: - refresh_skills() - except Exception as exc: - shutil.rmtree(skill_dir, ignore_errors=True) - raise HTTPException(status_code=422, detail=f"Skill files were written but failed to load: {exc}") + if _MATCREATOR_MODE == "server": + try: + await asyncio.to_thread(remove_worker, user_id) + await asyncio.to_thread(ensure_worker_running, user_id) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Skill was saved but the worker could not restart: {exc}") + else: + try: + refresh_skills() + except Exception as exc: + shutil.rmtree(skill_dirs[0], ignore_errors=True) + raise HTTPException(status_code=422, detail=f"Skill files were written but failed to load: {exc}") return JSONResponse({"status": "ok", "name": name, "references": ref_names, "scripts": script_names}) @app.delete("/api/skills/custom/{skill_name}") -async def delete_custom_skill(skill_name: str) -> JSONResponse: +async def delete_custom_skill(skill_name: str, user_id: str = Query(default="")) -> JSONResponse: """Delete a custom workspace skill. Default skills cannot be deleted.""" if not _SKILL_NAME_RE.match(skill_name): raise HTTPException(status_code=400, detail=f"Invalid skill name: '{skill_name}'.") @@ -3258,21 +3326,31 @@ async def delete_custom_skill(skill_name: str) -> JSONResponse: status_code=400, detail=f"'{skill_name}' is a built-in default skill and cannot be deleted.", ) - root = workspace_skills_dir() - skill_dir = root / skill_name - if skill_dir.resolve() == root.resolve() or not skill_dir.resolve().is_relative_to(root.resolve()): - raise HTTPException(status_code=400, detail="Invalid skill path.") - if not skill_dir.exists(): + roots = _custom_skill_roots(user_id) + skill_dirs = [root / skill_name for root in roots] + for root, skill_dir in zip(roots, skill_dirs): + if skill_dir.resolve() == root.resolve() or not skill_dir.resolve().is_relative_to(root.resolve()): + raise HTTPException(status_code=400, detail="Invalid skill path.") + if not any(skill_dir.exists() for skill_dir in skill_dirs): raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found in workspace.") try: - shutil.rmtree(skill_dir) + for skill_dir in skill_dirs: + if skill_dir.exists(): + shutil.rmtree(skill_dir) except OSError as exc: raise HTTPException(status_code=500, detail=f"Failed to delete skill: {exc}") - try: - refresh_skills() - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Skill deleted but registry reload failed: {exc}") + if _MATCREATOR_MODE == "server": + try: + await asyncio.to_thread(remove_worker, user_id) + await asyncio.to_thread(ensure_worker_running, user_id) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Skill was deleted but the worker could not restart: {exc}") + else: + try: + refresh_skills() + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Skill deleted but registry reload failed: {exc}") return JSONResponse({"status": "ok", "deleted": skill_name}) diff --git a/web/vite-frontend/src/features/settings/SettingsController.js b/web/vite-frontend/src/features/settings/SettingsController.js index cc84eddd..af73af74 100644 --- a/web/vite-frontend/src/features/settings/SettingsController.js +++ b/web/vite-frontend/src/features/settings/SettingsController.js @@ -435,7 +435,7 @@ export function createSettingsController({ state, applyLogin }) { uploadBtn.disabled = true; uploadBtn.textContent = "Uploading…"; try { - const resp = await fetch("/api/skills/custom", { method: "POST", body: formData }); + const resp = await fetch(settingsApiUrl("/api/skills/custom"), { method: "POST", body: formData }); const body = await resp.json().catch(() => ({})); if (!resp.ok) { errorEl.textContent = body.detail || `Upload failed (${resp.status})`; @@ -458,7 +458,7 @@ export function createSettingsController({ state, applyLogin }) { async function deleteCustomSkill(skillName) { try { - const resp = await fetch(`/api/skills/custom/${encodeURIComponent(skillName)}`, { method: "DELETE" }); + const resp = await fetch(settingsApiUrl(`/api/skills/custom/${encodeURIComponent(skillName)}`), { method: "DELETE" }); const body = await resp.json().catch(() => ({})); if (!resp.ok) { settingsStatus.textContent = body.detail || `Delete failed (${resp.status})`;