diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 392cdec..ef65369 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 + ref: d5e5177092d74de03588d8675f3504db36b7bacb path: .akashic-core - uses: actions/checkout@v4 with: diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 7f4da6f..ecf78fa 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,5 +1,5 @@ schema_version = 1 name = "meme" -version = "1.0.0" +version = "1.0.1" api_version = 3 entrypoint = "plugin.py" diff --git a/dashboard.py b/dashboard.py index fc996a0..c2c5bbe 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json import os +from pathlib import Path +from uuid import uuid4 from typing import Any from fastapi import FastAPI, HTTPException @@ -19,12 +22,14 @@ def get_meme_categories() -> dict[str, Any]: catalog._load() result: list[dict[str, Any]] = [] for tag, cat in catalog._categories.items(): - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) count = 0 if cat_dir.is_dir(): count = len([ f for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ]) result.append({ "tag": tag, @@ -39,57 +44,124 @@ def get_meme_categories() -> dict[str, Any]: @app.get("/api/dashboard/meme/images/{tag}") def get_meme_images(tag: str) -> dict[str, Any]: - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) images = [] if cat_dir.is_dir(): images = [ f.name for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ] images.sort() return {"tag": tag, "images": images} @app.delete("/api/dashboard/meme/categories/{tag}") def delete_meme_category(tag: str) -> dict[str, Any]: - catalog._load() - if tag not in catalog._categories: - raise HTTPException(status_code=404, detail="Category not found") - del catalog._categories[tag] - - manifest_path = memes_dir / "manifest.json" - if manifest_path.exists(): - import json - try: - data = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - data = {} - if "categories" in data and tag in data["categories"]: - del data["categories"][tag] - manifest_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - - import shutil - cat_dir = memes_dir / tag - if cat_dir.is_dir(): - shutil.rmtree(cat_dir, ignore_errors=True) - - return {"success": True} + return _remove_category(memes_dir, tag, catalog) @app.delete("/api/dashboard/meme/media/{tag}/{filename}") def delete_meme_media(tag: str, filename: str) -> dict[str, Any]: - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") - file_path.unlink() - return {"success": True} + recovery_id = _move_to_recovery(memes_dir, file_path, "image") + return {"success": True, "recovery_id": recovery_id} @app.get("/api/dashboard/meme/media/{tag}/{filename}") def get_meme_media(tag: str, filename: str) -> Any: - # Avoid path traversal attacks - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") return FileResponse(file_path) + + +def _safe_segment(value: str, label: str) -> str: + if ( + not value + or value in {".", ".."} + or "/" in value + or "\\" in value + or "\x00" in value + or os.path.basename(value) != value + ): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + return value + + +def _safe_path(root: Path, segments: tuple[tuple[str, str], ...]) -> Path: + base = root.resolve(strict=False) + candidate = base + for value, label in segments: + candidate /= _safe_segment(value, label) + if candidate.is_symlink(): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + if not candidate.resolve(strict=False).is_relative_to(base): + raise HTTPException(status_code=422, detail="Path escapes meme workspace") + return candidate + + +def _move_to_recovery(memes_dir: Path, source: Path, kind: str) -> str: + recovery_id = f"{kind}-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + source.replace(recovery_dir / source.name) + except OSError as error: + raise HTTPException(status_code=500, detail=f"Failed to preserve deleted {kind}") from error + return recovery_id + + +def _remove_category( + memes_dir: Path, + tag: str, + catalog: MemeCatalog, +) -> dict[str, object]: + manifest_path = _safe_path(memes_dir, (("manifest.json", "manifest"),)) + category_dir = _safe_path(memes_dir, ((tag, "category"),)) + try: + manifest_bytes = manifest_path.read_bytes() + manifest = json.loads(manifest_bytes) + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=500, detail="Meme manifest is unreadable") from error + categories = manifest.get("categories") if isinstance(manifest, dict) else None + if not isinstance(categories, dict): + raise HTTPException(status_code=500, detail="Meme manifest categories are invalid") + if tag not in categories: + raise HTTPException(status_code=404, detail="Category not found") + + recovery_id = f"category-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + moved_category: Path | None = None + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + (recovery_dir / "manifest.json").write_bytes(manifest_bytes) + if category_dir.exists(): + moved_category = recovery_dir / tag + category_dir.replace(moved_category) + del categories[tag] + _write_manifest(manifest_path, manifest) + except OSError as error: + if moved_category is not None and moved_category.exists(): + moved_category.replace(category_dir) + raise HTTPException(status_code=500, detail="Failed to preserve deleted category") from error + catalog._manifest_mtime = -1.0 + return {"success": True, "recovery_id": recovery_id} + + +def _write_manifest(path: Path, manifest: dict[str, object]) -> None: + staging = path.with_name(f".{path.name}.{uuid4().hex}.tmp") + try: + with staging.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staging, path) + finally: + staging.unlink(missing_ok=True) diff --git a/dashboard_panel.css b/dashboard_panel.css index 4e2fe95..8098bc0 100644 --- a/dashboard_panel.css +++ b/dashboard_panel.css @@ -1,12 +1,3 @@ -.plugin-workbench-pane:has(> .plugin-workbench-root[data-akashic-plugin="meme"]) { - overflow: hidden; -} - -.plugin-workbench-root[data-akashic-plugin="meme"] { - height: 100%; - min-height: 0; -} - .meme-dashboard { display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx index f31d2d2..a71a3b8 100644 --- a/dashboard_panel.tsx +++ b/dashboard_panel.tsx @@ -1,13 +1,24 @@ -/// import { useEffect, useState } from "react"; -import { api } from "@akashic/dashboard-ui"; - -// The Akashic Dashboard injects itself globally. -// We declare it here to satisfy TypeScript in our standalone build. -declare global { - interface Window { - AkashicDashboard: any; - } +import { createRoot } from "react-dom/client"; +import "./dashboard_panel.css"; +import type { WebHostContextV1, WebUiDisposer } from "@akashic/web-ui-v1"; +import type { WorkbenchPanelEntry } from "@akashic/workbench-ui-v2"; + +let dashboardRequest: WebHostContextV1["http"]["request"] | null = null; + +async function api(path: string, init?: RequestInit): Promise { + if (!dashboardRequest) throw new Error("Meme 工作台面板未激活"); + const response = await dashboardRequest(path, init); + const body = await response.json() as T & { detail?: unknown; message?: unknown }; + if (!response.ok) throw new Error(String(body.detail ?? body.message ?? `HTTP ${response.status}`)); + return body; +} + +async function media(path: string, signal: AbortSignal): Promise { + if (!dashboardRequest) throw new Error("Meme 工作台面板未激活"); + const response = await dashboardRequest(path, { signal }); + if (!response.ok) throw new Error(`图片读取失败:HTTP ${response.status}`); + return response.blob(); } interface Category { @@ -27,26 +38,38 @@ function MemeMain() { const [loading, setLoading] = useState(true); useEffect(() => { - void api("/api/dashboard/meme/categories").then((data: any) => { + const controller = new AbortController(); + void api("/api/dashboard/meme/categories", { signal: controller.signal }).then((data: any) => { + if (controller.signal.aborted) return; setCategories(data.categories || []); if (data.categories?.length > 0) { setSelectedTag(data.categories[0].tag); } setLoading(false); }, (reason: unknown) => { + if (controller.signal.aborted) return; setError(reason instanceof Error ? reason.message : "分类读取失败"); setLoading(false); }); + return () => controller.abort(); }, []); useEffect(() => { - if (selectedTag) { - void api(`/api/dashboard/meme/images/${selectedTag}`).then((data: any) => { - setImages(data.images || []); - }, (reason: unknown) => setError(reason instanceof Error ? reason.message : "图片读取失败")); - } else { + if (!selectedTag) { setImages([]); + return; } + const controller = new AbortController(); + setImages([]); + setError(null); + void api(`/api/dashboard/meme/images/${selectedTag}`, { signal: controller.signal }).then((data: any) => { + if (!controller.signal.aborted) setImages(data.images || []); + }, (reason: unknown) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : "图片读取失败"); + } + }); + return () => controller.abort(); }, [selectedTag]); return ( @@ -126,9 +149,7 @@ function MemeMain() { > ✕ -
- -
+
{img}
@@ -153,21 +174,46 @@ function MemeMain() { ); } -window.AkashicDashboard.registerPlugin({ +function MemeImage({ tag, name }: { tag: string; name: string }) { + const [source, setSource] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + let objectUrl: string | null = null; + void media(`/api/dashboard/meme/media/${tag}/${name}`, controller.signal).then((blob) => { + objectUrl = URL.createObjectURL(blob); + setSource(objectUrl); + }, (reason: unknown) => { + if (!controller.signal.aborted) console.error("Meme 图片读取失败", reason); + }); + return () => { + controller.abort(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [name, tag]); + + return ; +} + +const panel = { id: "meme", label: "Meme 表情包", viewLabel: "表情包", + order: 50, layout: "workbench", + rowKey: "id", + columns: [], - async getCount(): Promise { + async getCount({ signal }: { signal: AbortSignal }): Promise { try { - const data = await api("/api/dashboard/meme/categories"); + const data = await api("/api/dashboard/meme/categories", { signal }); let total = 0; for (const cat of data.categories || []) { total += cat.count; } return total; - } catch { + } catch (error) { + if (signal.aborted) throw error; return null; } }, @@ -176,5 +222,18 @@ window.AkashicDashboard.registerPlugin({ return { items: [], total: 0 }; }, - Main: MemeMain, -}); + renderMain(container: HTMLElement): WebUiDisposer { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, +} satisfies WorkbenchPanelEntry; + +export function activate(ctx: WebHostContextV1): WebUiDisposer { + dashboardRequest = ctx.http.request; + const release = ctx.ui.inject("workbench.panels.v2", (mount) => mount.register(panel)); + return () => { + release(); + dashboardRequest = null; + }; +} diff --git a/plugin.py b/plugin.py index fb579f1..88717d3 100644 --- a/plugin.py +++ b/plugin.py @@ -42,10 +42,16 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: api_version = 3 name = "meme" -version = "1.0.0" +version = "1.0.1" inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) skill_roots = ("skills",) dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v2",) +web_provides = () +web_contract_digests = { + "workbench.panels.v2": "fb6417c9bf532c1fdb344767d06065d5d3293da85deb64eff1e8088889a33bcb", +} workspace_roots = ("memes",) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 76fd844..2346682 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone import pytest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.routing import APIRoute from agent.core.response_parser import ResponseMetadata @@ -103,7 +103,7 @@ def test_static_manifest_matches_v3_module() -> None: ) assert manifest.name == _meme_plugin_module.name == "meme" - assert manifest.version == _meme_plugin_module.version == "1.0.0" + assert manifest.version == _meme_plugin_module.version == "1.0.1" assert manifest.api_version == _meme_plugin_module.api_version == 3 assert manifest.entrypoint == "plugin.py" @@ -118,7 +118,6 @@ def _prompt_ctx() -> PromptRenderCtx: timestamp=datetime.now(timezone.utc), history=[], skill_names=[], - retrieved_memory_block="", disabled_sections=set(), turn_injection_prompt="", ) @@ -205,6 +204,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "plugin-data", workspace=tmp_path, @@ -269,6 +269,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "candidate-data", workspace=candidate_workspace, @@ -315,6 +316,101 @@ async def mount(ctx: Context) -> None: await root.dispose() +def _meme_dashboard_route(tmp_path: Path, path: str, method: str): + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + app = FastAPI() + dashboard_module.register( + app, + DashboardContext( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_root=tmp_path / "data", + validation=True, + _workspace_roots=(("memes", tmp_path / "memes"),), + ), + ) + return next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == path + and method in route.methods + ) + + +def test_dashboard_rejects_media_path_traversal(tmp_path: Path) -> None: + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid category") as raised: + media_route.endpoint("..", "secret.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_rejects_media_symlink(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + secret = tmp_path / "secret.png" + secret.write_bytes(b"secret") + image.unlink() + image.symlink_to(secret) + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid filename") as raised: + media_route.endpoint("shy", "001.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_delete_keeps_named_recovery_copy(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "DELETE", + ) + + result = delete_route.endpoint("shy", "001.png") + + assert not image.exists() + recovery_id = result["recovery_id"] + assert recovery_id.startswith("image-") + recovered = tmp_path / "memes" / ".trash" / recovery_id / "001.png" + assert recovered.read_bytes() == b"\x89PNG\r\n\x1a\n" + + +def test_category_delete_restores_directory_when_manifest_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image = _write_meme_workspace(tmp_path) + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + + def fail_write(*_args) -> None: + raise OSError("disk full") + + monkeypatch.setattr(dashboard_module, "_write_manifest", fail_write) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/categories/{tag}", + "DELETE", + ) + + with pytest.raises(HTTPException, match="Failed to preserve deleted category"): + delete_route.endpoint("shy") + + assert image.is_file() + manifest = json.loads((tmp_path / "memes" / "manifest.json").read_text()) + assert "shy" in manifest["categories"] + + @pytest.mark.asyncio async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( tmp_path: Path, diff --git a/web_module.css b/web_module.css new file mode 100644 index 0000000..43faaeb --- /dev/null +++ b/web_module.css @@ -0,0 +1 @@ +.meme-dashboard{display:grid;grid-template-columns:minmax(220px,260px) minmax(0,1fr);flex:1;height:100%;min-height:0;gap:28px;overflow:hidden;padding:24px;color:var(--ak-color-text-primary);background:var(--ak-color-bg-canvas)}.meme-dashboard__sidebar{display:flex;min-height:0;flex-direction:column;gap:6px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:16px;border-inline-end:1px solid var(--ak-color-border-default)}.meme-dashboard__sidebar-heading,.meme-dashboard__header{display:flex;align-items:flex-end;justify-content:space-between;gap:12px}.meme-dashboard__sidebar-heading{min-height:40px;margin-block-end:6px}.meme-dashboard__sidebar-heading h2,.meme-dashboard__header h1{margin:0;color:var(--ak-color-text-primary);letter-spacing:-.02em}.meme-dashboard__sidebar-heading h2{font-size:15px}.meme-dashboard__sidebar-heading span,.meme-dashboard__total{color:var(--ak-color-text-muted);font-size:12px;font-variant-numeric:tabular-nums}.meme-category-row{display:grid;grid-template-columns:minmax(0,1fr) 40px;gap:4px}.meme-category,.meme-delete,.meme-item__delete{border:0;color:inherit;background:transparent;cursor:pointer}.meme-category{display:flex;min-width:0;min-height:60px;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;border-radius:6px;text-align:start}.meme-category:hover{background:var(--ak-color-bg-surface-high)}.meme-category.is-active{color:var(--ak-color-action-primary);background:var(--ak-color-action-soft);box-shadow:inset 3px 0 0 var(--ak-color-action-primary)}.meme-category:disabled{cursor:not-allowed;opacity:.5}.meme-category>span{display:grid;min-width:0;gap:3px}.meme-category strong,.meme-category small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.meme-category strong{font-size:14px;font-weight:650}.meme-category small{color:var(--ak-color-text-muted);font-size:12px}.meme-category b{color:var(--ak-color-text-muted);font-size:12px;font-variant-numeric:tabular-nums}.meme-delete,.meme-item__delete{display:grid;width:40px;height:40px;place-items:center;border-radius:6px;color:var(--ak-color-text-muted);font-size:14px}.meme-delete:hover,.meme-item__delete:hover{color:var(--ak-color-status-error);background:color-mix(in oklch,var(--ak-color-status-error) 12%,transparent)}.meme-dashboard button:focus-visible,.meme-dashboard summary:focus-visible{outline:2px solid var(--ak-color-action-primary);outline-offset:2px}.meme-dashboard__gallery{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column;overflow:hidden}.meme-dashboard__header{margin-bottom:20px}.meme-dashboard__header p{margin:0 0 5px;color:var(--ak-color-action-primary);font-size:11px;font-weight:700;letter-spacing:.09em}.meme-dashboard__header h1{font-size:clamp(22px,2vw,28px)}.meme-dashboard__description{margin-block-start:6px;color:var(--ak-color-text-muted);font-size:13px}.meme-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));align-content:start;gap:16px}.meme-item{position:relative;min-width:0;margin:0;padding:8px;border:1px solid var(--ak-color-border-default);border-radius:7px;background:var(--ak-color-bg-surface)}.meme-item__preview{display:grid;aspect-ratio:1;place-items:center;overflow:hidden;border-radius:4px;background:var(--ak-color-bg-surface-low)}.meme-item__preview img{display:block;width:100%;height:100%;object-fit:contain}.meme-item__delete{position:absolute;z-index:1;top:12px;right:12px;border:1px solid var(--ak-color-border-default);background:var(--ak-color-bg-surface)}.meme-item figcaption{overflow:hidden;margin-block-start:8px;color:var(--ak-color-text-muted);font-family:var(--mono);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.meme-state{display:grid;grid-column:1 / -1;min-height:180px;place-content:center;gap:6px;padding:24px;border:1px dashed var(--ak-color-border-strong);border-radius:7px;color:var(--ak-color-text-muted);text-align:center}.meme-state strong{color:var(--ak-color-text-primary)}.meme-state.is-error{border-color:color-mix(in oklch,var(--ak-color-status-error) 40%,var(--ak-color-border-default))}.meme-state.is-error strong{color:var(--ak-color-status-error)}.meme-dashboard__nav-empty{margin:8px 12px;color:var(--ak-color-text-muted);font-size:13px}@media(max-width:720px){.meme-dashboard{display:block;overflow-y:auto;padding:16px}.meme-dashboard__sidebar{display:grid;grid-auto-columns:minmax(180px,72vw);grid-auto-flow:column;gap:8px;overflow-x:auto;overflow-y:hidden;margin-block-end:24px;padding:0 0 12px;border-inline-end:0;border-block-end:1px solid var(--ak-color-border-default)}.meme-dashboard__sidebar-heading,.meme-dashboard__nav-empty{grid-column:1}.meme-category-row{grid-row:2}.meme-dashboard__gallery{overflow:visible}.meme-dashboard__header{align-items:flex-start;flex-wrap:wrap}.meme-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}}@media(prefers-reduced-motion:reduce){.meme-dashboard *,.meme-dashboard *:before,.meme-dashboard *:after{scroll-behavior:auto!important;transition-duration:.01ms!important}} diff --git a/web_module.js b/web_module.js new file mode 100644 index 0000000..afde58c --- /dev/null +++ b/web_module.js @@ -0,0 +1 @@ +import{useEffect as x,useState as p}from"react";import{createRoot as y}from"react-dom/client";import{jsx as r,jsxs as n}from"react/jsx-runtime";var b=null;async function u(o,t){if(!b)throw new Error("Meme 工作台面板未激活");let a=await b(o,t),s=await a.json();if(!a.ok)throw new Error(String(s.detail??s.message??`HTTP ${a.status}`));return s}async function _(o,t){if(!b)throw new Error("Meme 工作台面板未激活");let a=await b(o,{signal:t});if(!a.ok)throw new Error(`图片读取失败:HTTP ${a.status}`);return a.blob()}function w(){let[o,t]=p([]),[a,s]=p(null),[c,m]=p([]),[g,h]=p(null),[f,v]=p(!0);return x(()=>{let e=new AbortController;return u("/api/dashboard/meme/categories",{signal:e.signal}).then(i=>{e.signal.aborted||(t(i.categories||[]),i.categories?.length>0&&s(i.categories[0].tag),v(!1))},i=>{e.signal.aborted||(h(i instanceof Error?i.message:"分类读取失败"),v(!1))}),()=>e.abort()},[]),x(()=>{if(!a){m([]);return}let e=new AbortController;return m([]),h(null),u(`/api/dashboard/meme/images/${a}`,{signal:e.signal}).then(i=>{e.signal.aborted||m(i.images||[])},i=>{e.signal.aborted||h(i instanceof Error?i.message:"图片读取失败")}),()=>e.abort()},[a]),n("main",{className:"meme-dashboard","aria-labelledby":"meme-dashboard-title",children:[n("nav",{className:"meme-dashboard__sidebar","aria-label":"表情包分类",children:[n("div",{className:"meme-dashboard__sidebar-heading",children:[r("h2",{children:"分类"}),r("span",{children:o.length})]}),o.map(e=>n("div",{className:"meme-category-row",children:[n("button",{type:"button",className:`meme-category${a===e.tag?" is-active":""}`,onClick:()=>s(e.tag),"aria-current":a===e.tag?"page":void 0,disabled:!e.enabled,children:[n("span",{children:[r("strong",{children:e.name||e.tag}),r("small",{children:e.desc||e.tag})]}),r("b",{children:e.count})]}),r("button",{type:"button",className:"meme-delete",onClick:()=>{confirm(`确定要删除分类 "${e.tag}" 吗?这会永久删除该分类及其所有图片!`)&&u(`/api/dashboard/meme/categories/${e.tag}`,{method:"DELETE"}).then(()=>{t(i=>{let l=i.filter(d=>d.tag!==e.tag);return a===e.tag&&s(l.length>0?l[0].tag:null),l})}).catch(i=>alert("删除失败:"+i.message))},"aria-label":`删除分类 ${e.tag}`,title:"删除分类",children:"✕"})]},e.tag)),!f&&o.length===0&&r("p",{className:"meme-dashboard__nav-empty",children:"还没有分类。"})]}),n("section",{className:"meme-dashboard__gallery",children:[n("div",{className:"meme-dashboard__header",children:[n("div",{children:[r("p",{children:"表情包库"}),r("h1",{id:"meme-dashboard-title",children:a?o.find(e=>e.tag===a)?.name||a:"选择一个分类"}),r("div",{className:"meme-dashboard__description",children:a?o.find(e=>e.tag===a)?.desc:""})]}),n("span",{className:"meme-dashboard__total",children:[c.length," 张图片"]})]}),n("div",{className:"meme-grid","aria-live":"polite",children:[c.map(e=>n("figure",{className:"meme-item",children:[r("button",{type:"button",className:"meme-item__delete",onClick:i=>{i.stopPropagation(),confirm(`确定要删除图片 "${e}" 吗?`)&&u(`/api/dashboard/meme/media/${a}/${e}`,{method:"DELETE"}).then(()=>{m(l=>l.filter(d=>d!==e)),t(l=>l.map(d=>d.tag===a?{...d,count:d.count-1}:d))}).catch(l=>alert("删除失败:"+l.message))},"aria-label":`删除图片 ${e}`,title:"删除图片",children:"✕"}),r("div",{className:"meme-item__preview",children:r(k,{tag:a,name:e})}),r("figcaption",{title:e,children:e})]},e)),f&&r("div",{className:"meme-state",role:"status",children:"正在读取表情包分类…"}),!f&&!g&&c.length===0&&a&&n("div",{className:"meme-state",children:[r("strong",{children:"这个分类还没有图片"}),r("span",{children:"通过 Meme 插件导入图片后会显示在这里。"})]}),g&&n("div",{className:"meme-state is-error",role:"alert",children:[r("strong",{children:"无法加载表情包"}),r("span",{children:g})]})]})]})]})}function k({tag:o,name:t}){let[a,s]=p(null);return x(()=>{let c=new AbortController,m=null;return _(`/api/dashboard/meme/media/${o}/${t}`,c.signal).then(g=>{m=URL.createObjectURL(g),s(m)},g=>{c.signal.aborted||console.error("Meme 图片读取失败",g)}),()=>{c.abort(),m&&URL.revokeObjectURL(m)}},[t,o]),r("img",{src:a??void 0,alt:"",loading:"lazy"})}var E={id:"meme",label:"Meme 表情包",viewLabel:"表情包",order:50,layout:"workbench",rowKey:"id",columns:[],async getCount({signal:o}){try{let t=await u("/api/dashboard/meme/categories",{signal:o}),a=0;for(let s of t.categories||[])a+=s.count;return a}catch(t){if(o.aborted)throw t;return null}},async fetchPage(){return{items:[],total:0}},renderMain(o){let t=y(o);return t.render(r(w,{})),()=>t.unmount()}};function M(o){b=o.http.request;let t=o.ui.inject("workbench.panels.v2",a=>a.register(E));return()=>{t(),b=null}}export{M as activate};