Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
@@ -1,18 +1,115 @@
|
||||
import { memo } from "react";
|
||||
import { memo, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
|
||||
export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
||||
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
||||
|
||||
/** URL for a file in the project (absolute or relative to baseDir). */
|
||||
export function projectFileUrl(src: string, baseDir?: string): string | undefined {
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||
if (!baseDir && !src.startsWith("/")) return undefined;
|
||||
const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/");
|
||||
const out: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (p === "..") out.pop();
|
||||
else if (p && p !== ".") out.push(p);
|
||||
}
|
||||
return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`;
|
||||
}
|
||||
|
||||
function textOf(node: ReactNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(textOf).join("");
|
||||
if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** SVG code shown as an image (img context: scripts never run), code on demand. */
|
||||
export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) {
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
|
||||
return (
|
||||
<div className="svg-block">
|
||||
<img src={url} alt="SVG" />
|
||||
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
||||
{showCode ? "▾ SVG" : "▸ SVG"}
|
||||
</button>
|
||||
{showCode && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
||||
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
|
||||
components={{
|
||||
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
|
||||
img: ({ src, alt }) => {
|
||||
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
|
||||
if (!url) return <span>{alt}</span>;
|
||||
// Non-image files (3D models, documents) become links.
|
||||
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
📎 {alt || src}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
|
||||
},
|
||||
pre: ({ children }) => {
|
||||
const code = textOf(children);
|
||||
const child: any = Array.isArray(children) ? children[0] : children;
|
||||
const cls = String(child?.props?.className ?? "");
|
||||
if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("<svg"))) {
|
||||
return (
|
||||
<SvgBlock code={code}>
|
||||
<pre>{children}</pre>
|
||||
</SvgBlock>
|
||||
);
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Image files mentioned in a text (paths in backticks or plain). */
|
||||
export function imagePathsIn(text: string): string[] {
|
||||
const found = new Set<string>();
|
||||
const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim;
|
||||
for (const m of text.matchAll(re)) {
|
||||
// Skip URLs and markdown images (rendered anyway).
|
||||
const idx = m.index ?? 0;
|
||||
const before = text.slice(Math.max(0, idx - 2), idx + 1);
|
||||
if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue;
|
||||
found.add(m[1]);
|
||||
}
|
||||
return [...found].slice(0, 8);
|
||||
}
|
||||
|
||||
/** Thumbnails for image files mentioned in a message; missing files hide themselves. */
|
||||
export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||
const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, ""));
|
||||
if (paths.length === 0 || !baseDir) return null;
|
||||
return (
|
||||
<div className="image-refs">
|
||||
{paths.map((p) => {
|
||||
const url = projectFileUrl(p, baseDir)!;
|
||||
return (
|
||||
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
|
||||
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user