Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
schema_version = 1
name = "meme"
version = "1.0.0"
version = "1.0.1"
api_version = 3
entrypoint = "plugin.py"
142 changes: 107 additions & 35 deletions dashboard.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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)
9 changes: 0 additions & 9 deletions dashboard_panel.css
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
107 changes: 83 additions & 24 deletions dashboard_panel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
/// <reference path="../../types/akashic-dashboard.d.ts" />
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<T = any>(path: string, init?: RequestInit): Promise<T> {
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<Blob> {
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 {
Expand All @@ -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 (
Expand Down Expand Up @@ -126,9 +149,7 @@ function MemeMain() {
>
</button>
<div className="meme-item__preview">
<img src={`/api/dashboard/meme/media/${selectedTag}/${img}`} alt="" loading="lazy" />
</div>
<div className="meme-item__preview"><MemeImage tag={selectedTag!} name={img} /></div>
<figcaption title={img}>
{img}
</figcaption>
Expand All @@ -153,21 +174,46 @@ function MemeMain() {
);
}

window.AkashicDashboard.registerPlugin({
function MemeImage({ tag, name }: { tag: string; name: string }) {
const [source, setSource] = useState<string | null>(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 <img src={source ?? undefined} alt="" loading="lazy" />;
}

const panel = {
id: "meme",
label: "Meme 表情包",
viewLabel: "表情包",
order: 50,
layout: "workbench",
rowKey: "id",
columns: [],

async getCount(): Promise<number | null> {
async getCount({ signal }: { signal: AbortSignal }): Promise<number | null> {
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;
}
},
Expand All @@ -176,5 +222,18 @@ window.AkashicDashboard.registerPlugin({
return { items: [], total: 0 };
},

Main: MemeMain,
});
renderMain(container: HTMLElement): WebUiDisposer {
const root = createRoot(container);
root.render(<MemeMain />);
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;
};
}
8 changes: 7 additions & 1 deletion plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)


Expand Down
Loading
Loading