Files
SnapOtter/apps/demo/src/mock-api.ts
T
SnapOtterandGitHub 17726ae59d docs: multi-modality rebrand, 2.0 architecture accuracy, and full OpenAPI coverage (#254)
* docs: rebrand from image-only to multi-modality across docs and metadata

SnapOtter expanded from image-only to 157 tools across 5 modalities
(image, video, audio, document/PDF, data). Update all product-level
copy, metadata, and i18n that still framed it as an image-only tool.

- README, package.json, root llms.txt: multi-modality framing, 157 tools
- OpenAPI info + tags, generated /llms.txt tagline (docs.ts)
- VitePress docs site: hero, getting-started, architecture, security,
  deployment, configuration, developer, supported-formats
- i18n: 10 product keys across all 21 locales (hero, app description,
  privacy notes, AI features, progress messages, getting-started)
- web/demo/landing meta + privacy copy, COMMUNITY_GUIDE, .env.example

Stale tool counts (53/50+/52/70+/35) corrected to 157 throughout.
Database/container deployment claims left unchanged (out of scope).

* docs: fix stale post-rebrand test assertions and README language list

- tests/e2e-docs/homepage.spec.ts: assert the current docs homepage (file toolkit, 157 tools, 5 modalities) instead of the old image-only strings
- tests/unit/api/docs-route.test.ts: sync the reproduced llms.txt tagline with docs.ts
- README.md: 21 languages with the correct list (add Swedish and Chinese Traditional, drop Czech which is not supported)

* docs: correct 2.0 architecture references (Postgres 17 + Redis 8, 3-container stack)

The docs and metadata still described the 1.x stack (SQLite, single container, p-queue). Update them to the current 2.0 reality.

- README: replace the broken single-container `docker run` quick-start with the real Docker Compose stack (app + Postgres 17 + Redis 8); fix the "no Redis, no Postgres" feature bullet
- package.json: description no longer claims a single container
- apps/docs: rewrite database.md for Postgres; configuration.md DB_PATH -> DATABASE_URL + REDIS_URL; architecture.md SQLite/p-queue/better-sqlite3 -> Postgres/BullMQ/pg and add media-engine + doc-engine; developer/security/deployment/docker-tags/getting-started/contributing compose examples now include postgres + redis; index.md + api/ai.md AI count 16 -> 19
- SECURITY.md: Drizzle (SQLite) -> (PostgreSQL)
- landing: enterprise/FeatureHighlights single-container wording; TrustSignals/ToolGrid 150+ -> 157 (dynamic); Pricing/FAQ 15 -> 19 AI tools

* docs(api): document all video, audio, document, and data tool endpoints in OpenAPI

The spec covered only image tools; the Scalar UI and the generated /llms.txt and /llms-full.txt inherited that gap. Add the 104 missing tool endpoints so the API docs match the code.

- Video: 29 endpoints (most long/async; auto-subtitles is AI)
- Audio: 17 (transcribe-audio is AI)
- Document/PDF: 36 (ocr-pdf is AI; conversions are long/async)
- Data: 10
- Image: 12 newer tools (background-replace, blur-background AI; histogram/lqip-placeholder/sprite-sheet custom responses; barcode-generate uses a JSON body)

Each schema is derived from the tool's Zod validator and executionHint (fast -> 200, long -> 202+SSE, AI adds 501 FeatureNotInstalledError, multi-file inputs as arrays), referencing the existing shared schemas. Tool path entries: 64 -> 168. Spec parses as valid YAML with no duplicate paths and only known $refs.
2026-06-16 18:04:52 +08:00

352 lines
10 KiB
TypeScript

const PERMISSIONS = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"features:manage",
"system:health",
"audit:read",
];
const FEATURE_BUNDLES = [
{
id: "background-removal",
name: "Background Removal",
description: "Remove image backgrounds with AI",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "4-5 GB",
enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
progress: null,
error: null,
},
{
id: "face-detection",
name: "Face Detection",
description: "Detect and blur faces, fix red-eye, smart crop",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "200-300 MB",
enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
progress: null,
error: null,
},
{
id: "object-eraser-colorize",
name: "Object Eraser & Colorize",
description: "Erase objects from photos and colorize B&W images",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "1-2 GB",
enablesTools: ["erase-object", "colorize", "ai-canvas-expand"],
progress: null,
error: null,
},
{
id: "upscale-enhance",
name: "Upscale & Enhance",
description: "AI upscaling, face enhancement, and noise removal",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "4-5 GB",
enablesTools: ["upscale", "enhance-faces", "noise-removal"],
progress: null,
error: null,
},
{
id: "photo-restoration",
name: "Photo Restoration",
description: "Restore old or damaged photos",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "800 MB - 1 GB",
enablesTools: ["restore-photo"],
progress: null,
error: null,
},
{
id: "ocr",
name: "OCR",
description: "Extract text from images",
status: "installed",
installedVersion: "1.0.0",
estimatedSize: "3-4 GB",
enablesTools: ["ocr"],
progress: null,
error: null,
},
];
const STATE_KEY = "snapotter-demo-state";
function loadState(): {
passwordChanged: boolean;
analyticsEnabled: boolean | null;
analyticsConsentShownAt: number | null;
} {
try {
const raw = localStorage.getItem(STATE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return { passwordChanged: false, analyticsEnabled: null, analyticsConsentShownAt: null };
}
function saveState(patch: Partial<ReturnType<typeof loadState>>) {
const state = { ...loadState(), ...patch };
localStorage.setItem(STATE_KEY, JSON.stringify(state));
}
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function matchRoute(url: string, method: string): Response | null {
const path = new URL(url, "http://localhost").pathname;
if (path === "/api/v1/config/auth" && method === "GET") {
return json({ authEnabled: true, oidcEnabled: false });
}
if (path === "/api/auth/login" && method === "POST") {
localStorage.setItem("snapotter-token", "demo-token");
return json({ token: "demo-token" });
}
if (path === "/api/auth/session" && method === "GET") {
const token = localStorage.getItem("snapotter-token");
if (!token) return json({ error: "Unauthorized" }, 401);
const state = loadState();
return json({
user: {
id: 1,
username: "demo",
displayName: "Demo User",
role: "admin",
permissions: PERMISSIONS,
mustChangePassword: !state.passwordChanged,
analyticsEnabled: state.analyticsEnabled,
analyticsConsentShownAt: state.analyticsConsentShownAt,
analyticsConsentRemindAt: null,
loginMethod: "local",
hasLocalPassword: true,
},
});
}
if (path === "/api/auth/logout" && method === "POST") {
localStorage.removeItem("snapotter-token");
localStorage.removeItem(STATE_KEY);
return json({ ok: true });
}
if (path === "/api/v1/health") {
return json({ status: "ok", version: "1.17.0" });
}
if (path === "/api/v1/settings" && method === "GET") {
return json({
settings: {
disabledTools: "[]",
enableExperimentalTools: "false",
defaultToolView: "sidebar",
defaultTheme: "system",
},
});
}
if (path === "/api/v1/features" && method === "GET") {
return json({ bundles: FEATURE_BUNDLES });
}
if (path === "/api/v1/config/analytics" && method === "GET") {
return json({
enabled: true,
posthogApiKey: "",
posthogHost: "",
sentryDsn: "",
sampleRate: 0,
instanceId: "demo-instance",
});
}
if (path === "/api/v1/user/analytics" && method === "PUT") {
saveState({ analyticsEnabled: true, analyticsConsentShownAt: Date.now() });
return json({ ok: true });
}
if (path.startsWith("/api/v1/tools/") && method === "POST") {
return json(
{
error:
"This is a demo instance. To process files, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
},
403,
);
}
if (path === "/api/v1/upload" && method === "POST") {
return json(
{
error:
"This is a demo instance. To upload files, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
},
403,
);
}
if (path === "/api/v1/files" && method === "GET") {
return json({ files: [], total: 0 });
}
if (path === "/api/v1/files" && method === "DELETE") {
return json({ deleted: 0 });
}
if (path.startsWith("/api/v1/admin/")) {
return json({ error: "Admin actions are disabled in this demo." }, 403);
}
if (path === "/api/auth/change-password" && method === "POST") {
saveState({ passwordChanged: true });
return json({ ok: true });
}
if (path.startsWith("/api/v1/pipelines") && method === "GET") {
return json({ pipelines: [] });
}
if (path.startsWith("/api/v1/pipelines") && method === "POST") {
return json({ error: "Pipelines are disabled in this demo." }, 403);
}
if (path.startsWith("/api/v1/audit") && method === "GET") {
return json({ entries: [], total: 0 });
}
if (path.startsWith("/api/v1/users") && method === "GET") {
return json({
users: [
{
id: 1,
username: "demo",
displayName: "Demo User",
role: "admin",
createdAt: new Date().toISOString(),
},
],
});
}
if (path.startsWith("/api/v1/teams") && method === "GET") {
return json({ teams: [] });
}
if (path.startsWith("/api/v1/apikeys") && method === "GET") {
return json({ apiKeys: [] });
}
if (path.startsWith("/api/v1/roles") && method === "GET") {
return json({ roles: [] });
}
if (path.startsWith("/api/")) {
return json({ ok: true });
}
return null;
}
const originalFetch = window.fetch.bind(window);
export function installMocks() {
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const method = init?.method?.toUpperCase() || "GET";
const mock = matchRoute(url, method);
if (mock) return mock;
return originalFetch(input, init);
};
const OriginalXHR = window.XMLHttpRequest;
const MockXHR = class extends OriginalXHR {
private _url = "";
private _method = "";
open(method: string, url: string | URL, ...args: unknown[]) {
this._method = method.toUpperCase();
this._url = typeof url === "string" ? url : url.href;
// @ts-expect-error -- variadic override
super.open(method, url, ...args);
}
send(body?: Document | XMLHttpRequestBodyInit | null) {
if (this._url.startsWith("/api/")) {
const mock = matchRoute(this._url, this._method);
if (mock) {
setTimeout(async () => {
const responseText = await mock.text();
Object.defineProperty(this, "status", { value: mock.status, writable: false });
Object.defineProperty(this, "readyState", { value: 4, writable: false });
Object.defineProperty(this, "responseText", { value: responseText, writable: false });
Object.defineProperty(this, "response", { value: responseText, writable: false });
this.dispatchEvent(new Event("loadstart"));
this.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
if (this.upload) {
this.upload.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
this.upload.dispatchEvent(new ProgressEvent("load", { loaded: 100, total: 100 }));
}
this.dispatchEvent(new Event("load"));
this.dispatchEvent(new Event("loadend"));
if (typeof this.onreadystatechange === "function") {
this.onreadystatechange(new Event("readystatechange") as ProgressEvent);
}
if (typeof this.onload === "function") {
this.onload(new ProgressEvent("load"));
}
}, 100);
return;
}
}
super.send(body);
}
};
window.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest;
const originalSubmit = HTMLFormElement.prototype.submit;
HTMLFormElement.prototype.submit = function () {
if (this.method.toUpperCase() === "POST") {
const target = this.action || window.location.href;
const url = new URL(target, window.location.origin);
window.location.href = url.pathname;
return;
}
originalSubmit.call(this);
};
const OriginalEventSource = window.EventSource;
window.EventSource = class extends OriginalEventSource {
constructor(url: string | URL, init?: EventSourceInit) {
const urlStr = typeof url === "string" ? url : url.href;
if (urlStr.startsWith("/api/")) {
super("about:blank", init);
setTimeout(() => this.close(), 0);
return;
}
super(url, init);
}
} as typeof EventSource;
}