Backport generic tool and preview improvements
This commit is contained in:
1 parent
80b9cc1547
commit
83a1049fe2
6 files changed
+236
-66
No files matched your search
@@ -86,9 +86,10 @@ HOST=127.0.0.1 PORT=8085 PI_WEBUI_TOKEN=change-me npm start
|
||||
- Startup resource summary for the model, session, tools, and advertised skills
|
||||
- Activity and compaction status during long turns
|
||||
- Prompt history through the Up and Down keys
|
||||
- Structured tool-result summaries and paged conversation loading
|
||||
- Structured tool-result summaries and one-toggle Input and Result details
|
||||
- Authenticated previews and downloads for files in the agent home directory
|
||||
- Built-in skills manager at `/skills` for private and shared skills
|
||||
- Responsive session sidebar and mobile top-bar controls
|
||||
- Expanded-by-default responsive session sidebar and mobile top-bar controls
|
||||
- Dark/light theme toggle with green accent
|
||||
|
||||
## Project structure
|
||||
|
||||
+38
-6
@@ -153,6 +153,43 @@ textarea {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-preview-page {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.file-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.file-preview-header h1 { margin: 0; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-preview-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.file-preview-header a,
|
||||
.file-preview-header button { color: inherit; }
|
||||
.file-preview-header button { border: 1px solid currentColor; border-radius: 6px; background: transparent; cursor: pointer; font: inherit; padding: 0.35rem 0.6rem; }
|
||||
.file-preview-header button:hover { background: color-mix(in oklab, currentColor 14%, transparent); }
|
||||
.file-preview-path { padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); background: var(--card); font-family: ui-monospace, monospace; overflow: auto; }
|
||||
.file-preview-body { max-width: 980px; margin: 1rem auto; padding: 1.5rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; }
|
||||
.file-preview-tabs { display: flex; gap: 0.35rem; margin: -0.5rem -0.5rem 1rem; padding: 0.25rem; border-bottom: 1px solid var(--border); }
|
||||
.file-preview-tabs button { border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted-foreground); cursor: pointer; font: inherit; font-weight: 700; padding: 0.4rem 0.65rem; }
|
||||
.file-preview-tabs button:hover, .file-preview-tabs button.active { border-color: var(--border); background: var(--muted); color: var(--foreground); }
|
||||
.file-preview-content { min-width: 0; }
|
||||
.file-preview-pdf { display: block; width: 100%; min-height: 70vh; border: 0; }
|
||||
.file-preview-image { display: block; max-width: 100%; max-height: 75vh; margin: auto; }
|
||||
.file-preview-text { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; }
|
||||
.file-preview-copy-error { align-self: center; color: var(--destructive); font-size: 0.8rem; margin-left: auto; }
|
||||
|
||||
.workspace-file-link { display: inline-flex; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--primary); }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
|
||||
.sidebar-overlay {
|
||||
@@ -462,18 +499,13 @@ textarea {
|
||||
padding: 0 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.progressive-tool-section-label,
|
||||
.progressive-tool-input-details summary {
|
||||
.progressive-tool-section-label {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progressive-tool-input-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 1; }
|
||||
|
||||
+130
-56
@@ -2,7 +2,7 @@ import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import "@mariozechner/mini-lit/dist/MarkdownBlock.js";
|
||||
import "./app.css";
|
||||
import { installMarkdownSecurityPatch } from "./markdown-security-patch.js";
|
||||
import { summarizeToolResultData } from "./tool-result-summary.js";
|
||||
import { summarizeToolResultContent, summarizeToolResultData } from "./tool-result-summary.js";
|
||||
|
||||
import { html, render, nothing } from "lit";
|
||||
import { icon } from "@mariozechner/mini-lit";
|
||||
@@ -40,6 +40,14 @@ function toolResultText(result: ToolResultMessage | undefined): string {
|
||||
.join("\n") || "";
|
||||
}
|
||||
|
||||
function fileViewerHref(filePath: string): string {
|
||||
return `/file-preview?${new URLSearchParams({ path: filePath }).toString()}`;
|
||||
}
|
||||
|
||||
function renderWorkspaceFileLink(filePath: string) {
|
||||
return html`<a class="workspace-file-link" href=${fileViewerHref(filePath)} target="_blank" rel="noopener noreferrer">Open ${filePath}</a>`;
|
||||
}
|
||||
|
||||
function prettyToolValue(value: unknown): { code: string; language: string } {
|
||||
try {
|
||||
if (typeof value === "string") return { code: JSON.stringify(JSON.parse(value), null, 2), language: "json" };
|
||||
@@ -50,11 +58,8 @@ function prettyToolValue(value: unknown): { code: string; language: string } {
|
||||
}
|
||||
|
||||
const toolCollapseOverrides = new Map<string, "full" | "collapsed">();
|
||||
const autoCollapsedToolIds = new Set<string>();
|
||||
const seenToolCallIds = new Set<string>();
|
||||
let activeToolCallId = "";
|
||||
|
||||
type ToolProgressiveMode = "input" | "result" | "full" | "collapsed";
|
||||
type ToolProgressiveMode = "full" | "collapsed";
|
||||
|
||||
function toolArgsObject(args: any): Record<string, any> {
|
||||
if (!args) return {};
|
||||
@@ -84,6 +89,8 @@ function summarizeToolCall(toolName: string, args: any): string {
|
||||
|
||||
function summarizeToolResult(result: ToolResultMessage | undefined): string {
|
||||
if (!result) return "";
|
||||
const contentSummary = summarizeToolResultContent((result as any).content);
|
||||
if (contentSummary) return contentSummary;
|
||||
const text = toolResultText(result);
|
||||
let parsed: any;
|
||||
try { parsed = text ? JSON.parse(text) : result.details; } catch { parsed = result.details; }
|
||||
@@ -103,32 +110,13 @@ function allVisibleToolCallIds(): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function progressiveToolMode(toolCallId: string, _hasResult: boolean): ToolProgressiveMode {
|
||||
const override = toolCollapseOverrides.get(toolCallId);
|
||||
return override === "full" ? "full" : "collapsed";
|
||||
}
|
||||
|
||||
function collapsePreviousToolCallsFor(newToolCallId: string) {
|
||||
if (!newToolCallId || activeToolCallId === newToolCallId) return;
|
||||
for (const id of seenToolCallIds) {
|
||||
if (id !== newToolCallId && !toolCollapseOverrides.has(id)) autoCollapsedToolIds.add(id);
|
||||
}
|
||||
activeToolCallId = newToolCallId;
|
||||
autoCollapsedToolIds.delete(newToolCallId);
|
||||
}
|
||||
|
||||
function noteVisibleToolCalls(message: any) {
|
||||
if (message?.role !== "assistant" || !Array.isArray(message.content)) return;
|
||||
for (const part of message.content) {
|
||||
if (part?.type !== "toolCall" || typeof part.id !== "string" || seenToolCallIds.has(part.id)) continue;
|
||||
collapsePreviousToolCallsFor(part.id);
|
||||
seenToolCallIds.add(part.id);
|
||||
}
|
||||
function progressiveToolMode(toolCallId: string): ToolProgressiveMode {
|
||||
return toolCollapseOverrides.get(toolCallId) ?? "collapsed";
|
||||
}
|
||||
|
||||
function renderProgressiveToolMessage(toolCall: any, result: ToolResultMessage | undefined, pending: boolean, aborted: boolean, isStreaming: boolean, host: any) {
|
||||
const effectiveResult = aborted ? ({ isError: true, content: [], toolCallId: toolCall.id, toolName: toolCall.name, timestamp: Date.now() } as any) : result;
|
||||
const mode = progressiveToolMode(toolCall.id, Boolean(effectiveResult));
|
||||
const mode = progressiveToolMode(toolCall.id);
|
||||
const args = toolArgsObject(toolCall.arguments);
|
||||
const commandInput = toolCall.name === "bash" && typeof args.command === "string" ? { code: args.command, language: "bash" } : undefined;
|
||||
const input = commandInput || prettyToolValue(toolCall.arguments);
|
||||
@@ -143,8 +131,7 @@ function renderProgressiveToolMessage(toolCall: any, result: ToolResultMessage |
|
||||
toolCollapseOverrides.set(toolCall.id, mode === "collapsed" ? "full" : "collapsed");
|
||||
host.requestUpdate();
|
||||
};
|
||||
const showInput = mode === "input" || mode === "full";
|
||||
const showResult = Boolean(effectiveResult) && (mode === "result" || mode === "full");
|
||||
const showDetails = mode === "full";
|
||||
|
||||
return html`
|
||||
<div class="progressive-tool-card ${mode} ${status}">
|
||||
@@ -156,20 +143,14 @@ function renderProgressiveToolMessage(toolCall: any, result: ToolResultMessage |
|
||||
</span>
|
||||
<span class="progressive-tool-state">${resultSummary || status}</span>
|
||||
</button>
|
||||
${mode === "collapsed" ? nothing : html`
|
||||
${!showDetails ? nothing : html`
|
||||
<div class="progressive-tool-body">
|
||||
${showInput ? html`
|
||||
<div class="progressive-tool-section">
|
||||
<div class="progressive-tool-section-label">Input</div>
|
||||
<code-block .code=${input.code} language=${input.language}></code-block>
|
||||
</div>
|
||||
` : effectiveResult ? html`
|
||||
<details class="progressive-tool-input-details">
|
||||
<summary>Input</summary>
|
||||
<code-block .code=${input.code} language=${input.language}></code-block>
|
||||
</details>
|
||||
` : nothing}
|
||||
${showResult ? html`
|
||||
${["read", "write", "edit"].includes(toolCall.name) && typeof args.path === "string" && args.path.trim() ? renderWorkspaceFileLink(args.path) : nothing}
|
||||
<div class="progressive-tool-section">
|
||||
<div class="progressive-tool-section-label">Input</div>
|
||||
<code-block .code=${input.code} language=${input.language}></code-block>
|
||||
</div>
|
||||
${effectiveResult ? html`
|
||||
<div class="progressive-tool-section">
|
||||
<div class="progressive-tool-section-label">Result</div>
|
||||
<code-block .code=${output.code} language=${output.language}></code-block>
|
||||
@@ -283,7 +264,7 @@ const promptHistory: string[] = [];
|
||||
let promptHistoryIndex: number | undefined;
|
||||
let promptHistoryDraft = "";
|
||||
let errorClearTimer: number | undefined;
|
||||
let sidebarOpen = false;
|
||||
let sidebarOpen = true;
|
||||
let showMobileControls = false;
|
||||
let sessionList: SessionListItem[] = [];
|
||||
let sessionsLoading = false;
|
||||
@@ -479,12 +460,7 @@ function applyStateSync(state: SerializedAgentState) {
|
||||
systemPrompt = state.systemPrompt;
|
||||
if (state.model) currentModel = state.model;
|
||||
if (state.errorMessage) errorMessage = state.errorMessage;
|
||||
if (currentSessionId && currentSessionId !== state.sessionId) {
|
||||
toolCollapseOverrides.clear();
|
||||
autoCollapsedToolIds.clear();
|
||||
seenToolCallIds.clear();
|
||||
activeToolCallId = "";
|
||||
}
|
||||
if (currentSessionId && currentSessionId !== state.sessionId) toolCollapseOverrides.clear();
|
||||
currentSessionId = state.sessionId;
|
||||
currentSessionName = state.sessionName;
|
||||
currentSessionPath = state.sessionPath;
|
||||
@@ -539,14 +515,12 @@ function handleAgentEvent(event: any) {
|
||||
|
||||
case "message_update":
|
||||
activityMessage = "";
|
||||
noteVisibleToolCalls(event.message);
|
||||
streamingMessage = event.message;
|
||||
updateStreamingContainer(event.message, true);
|
||||
requestAnimationFrame(() => scrollMessagesToBottom(true));
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
noteVisibleToolCalls(event.message);
|
||||
if (event.message) {
|
||||
const existing = messages.findIndex(
|
||||
(m: any) => m.timestamp === (event.message as any).timestamp && m.role === event.message.role
|
||||
@@ -593,10 +567,6 @@ function handleAgentEvent(event: any) {
|
||||
break;
|
||||
case "tool_execution_start":
|
||||
activityMessage = `Running ${event.toolName || event.name || event.toolCall?.name || "tool"}...`;
|
||||
if (typeof event.toolCallId === "string" && !seenToolCallIds.has(event.toolCallId)) {
|
||||
collapsePreviousToolCallsFor(event.toolCallId);
|
||||
seenToolCallIds.add(event.toolCallId);
|
||||
}
|
||||
renderApp();
|
||||
break;
|
||||
case "tool_execution_update":
|
||||
@@ -1441,11 +1411,115 @@ function renderApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
// ── File preview route ──
|
||||
|
||||
function isMarkdownPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase();
|
||||
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
||||
}
|
||||
|
||||
function isPdfPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
function isImagePath(filePath: string): boolean {
|
||||
return /\.(png|jpe?g|gif|webp)$/i.test(filePath);
|
||||
}
|
||||
|
||||
async function renderFilePreviewApp() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
const pathParam = new URLSearchParams(location.search).get("path") || "";
|
||||
const title = pathParam.split(/[\\\\/]/).pop() || "File preview";
|
||||
const pdfPreview = isPdfPath(pathParam);
|
||||
const imagePreview = isImagePath(pathParam);
|
||||
const inlinePreview = pdfPreview || imagePreview;
|
||||
let inlineSource = "";
|
||||
const apiParams = new URLSearchParams({ path: pathParam, [inlinePreview ? "inline" : "raw"]: "1" });
|
||||
let state: "loading" | "ready" | "error" = pathParam ? "loading" : "error";
|
||||
let content = "";
|
||||
let error = pathParam ? "" : "No file path was specified.";
|
||||
let mode: "rendered" | "plain" = isMarkdownPath(pathParam) ? "rendered" : "plain";
|
||||
let copyState: "idle" | "copied" | "error" = "idle";
|
||||
|
||||
const downloadFile = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/files?${new URLSearchParams({ path: pathParam, download: "1" })}`, { headers: { "X-PI-WEBUI-Token": getWebUiToken() } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(await response.blob());
|
||||
link.download = title;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
} catch (err) {
|
||||
error = `Could not download the file: ${err instanceof Error ? err.message : String(err)}`;
|
||||
state = "error";
|
||||
renderPage();
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async () => {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(content);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = content;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
if (!document.execCommand("copy")) throw new Error("copy_failed");
|
||||
textarea.remove();
|
||||
}
|
||||
copyState = "copied";
|
||||
} catch {
|
||||
copyState = "error";
|
||||
}
|
||||
renderPage();
|
||||
};
|
||||
const renderPage = () => render(html`
|
||||
<div class="file-preview-page">
|
||||
<header class="file-preview-header"><h1>${title}</h1><div class="file-preview-actions">
|
||||
${state === "ready" && !inlinePreview ? html`<button type="button" @click=${copyText}>${copyState === "copied" ? "Copied" : "Copy"}</button>` : nothing}
|
||||
<button type="button" @click=${downloadFile}>Download</button>
|
||||
</div></header>
|
||||
<div class="file-preview-path">${pathParam}</div>
|
||||
<main class="file-preview-body">
|
||||
${pdfPreview && inlineSource ? html`<iframe class="file-preview-pdf" title=${title} src=${inlineSource}></iframe>` : imagePreview && inlineSource ? html`<img class="file-preview-image" alt=${title} src=${inlineSource}>` : state === "loading" ? html`<p>Loading...</p>` : state === "error" ? html`<p>${error}</p>` : html`
|
||||
<div class="file-preview-tabs" role="tablist" aria-label="File view mode">
|
||||
${isMarkdownPath(pathParam) ? html`<button type="button" role="tab" aria-selected=${mode === "rendered"} class=${mode === "rendered" ? "active" : ""} @click=${() => { mode = "rendered"; renderPage(); }}>Rendered</button>` : nothing}
|
||||
<button type="button" role="tab" aria-selected=${mode === "plain"} class=${mode === "plain" ? "active" : ""} @click=${() => { mode = "plain"; renderPage(); }}>Plaintext</button>
|
||||
${copyState === "error" ? html`<span class="file-preview-copy-error" role="status">Could not copy the file text.</span>` : nothing}
|
||||
</div>
|
||||
<section class="file-preview-content">${isMarkdownPath(pathParam) && mode === "rendered" ? html`<div class="file-preview-markdown"><markdown-block .content=${content}></markdown-block></div>` : html`<pre class="file-preview-text">${content}</pre>`}</section>
|
||||
`}
|
||||
</main>
|
||||
</div>
|
||||
`, app);
|
||||
renderPage();
|
||||
if (!pathParam) return;
|
||||
try {
|
||||
const response = await fetch(`/api/files?${apiParams}`, { headers: { "X-PI-WEBUI-Token": getWebUiToken() } });
|
||||
if (!response.ok) throw new Error(await response.text() || `HTTP ${response.status}`);
|
||||
if (inlinePreview) {
|
||||
inlineSource = URL.createObjectURL(await response.blob());
|
||||
} else {
|
||||
content = await response.text();
|
||||
}
|
||||
state = "ready";
|
||||
} catch (err) {
|
||||
state = "error";
|
||||
error = `Could not load the preview: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
renderPage();
|
||||
}
|
||||
|
||||
if (location.pathname === "/skills") {
|
||||
renderSkillsManagerApp();
|
||||
void loadSkills();
|
||||
} else if (location.pathname === "/file-preview") {
|
||||
void renderFilePreviewApp();
|
||||
} else {
|
||||
renderApp();
|
||||
connectWs();
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export type ToolResultSummaryContentPart = { type?: string; mimeType?: string };
|
||||
|
||||
export function summarizeToolResultContent(content: ToolResultSummaryContentPart[] | undefined): string {
|
||||
if (!Array.isArray(content)) return "";
|
||||
const images = content.filter((part) => part?.type === "image");
|
||||
if (images.length === 0) return "";
|
||||
const mimeTypes = Array.from(new Set(images.map((part) => String(part.mimeType || "").trim()).filter(Boolean)));
|
||||
const label = `${images.length.toLocaleString()} image${images.length === 1 ? "" : "s"}`;
|
||||
return mimeTypes.length > 0 ? `${label} · ${mimeTypes.join(", ")}` : label;
|
||||
}
|
||||
|
||||
function resultCount(value: any): number | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
if (typeof value.count === "number") return value.count;
|
||||
|
||||
+47
-1
@@ -64,7 +64,7 @@ const allowedOrigins = new Set(
|
||||
);
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
csp: "default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none';",
|
||||
csp: "default-src 'self'; base-uri 'self'; form-action 'self'; img-src 'self' data: blob:; frame-src 'self' blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none';",
|
||||
hsts: "max-age=31536000; includeSubDomains; preload",
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
frameOptions: "DENY",
|
||||
@@ -389,6 +389,12 @@ function requireEditAuth(req: express.Request, res: express.Response, next: expr
|
||||
res.status(401).json({ ok: false, error: "unauthorized" });
|
||||
}
|
||||
|
||||
function requireHttpAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
if (!isAllowedWsOrigin(req.header("Origin"), req.header("Host"))) return res.status(403).type("text/plain").send("Forbidden origin");
|
||||
if (isValidToken(tokenFromRequest(req))) return next();
|
||||
res.status(401).type("text/plain").send("Unauthorized");
|
||||
}
|
||||
|
||||
function fileStamp(filePath: string): string {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
@@ -1467,6 +1473,46 @@ app.delete("/api/skills/user/:id", requireEditAuth, async (req, res) => {
|
||||
|
||||
// Serve static client files in production after API routes.
|
||||
const clientDist = path.resolve(__dirname, "../dist");
|
||||
const TEXT_PREVIEW_EXTENSIONS = new Set([
|
||||
".txt", ".md", ".markdown", ".json", ".jsonl", ".csv", ".tsv", ".log", ".xml", ".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".php", ".sh", ".bash", ".zsh", ".yaml", ".yml", ".toml", ".ini", ".conf", ".sql",
|
||||
]);
|
||||
|
||||
async function resolveHomeFilePath(requested: string): Promise<string | undefined> {
|
||||
const trimmed = requested.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const home = await fs.promises.realpath(os.homedir()).catch(() => undefined);
|
||||
const requestedPath = path.resolve(path.isAbsolute(trimmed) ? trimmed : path.join(os.homedir(), trimmed));
|
||||
const resolved = await fs.promises.realpath(requestedPath).catch(() => undefined);
|
||||
if (!home || !resolved || (resolved !== home && !resolved.startsWith(`${home}${path.sep}`))) return undefined;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function canPreviewTextFile(filePath: string, size: number): boolean {
|
||||
return size <= 2 * 1024 * 1024 && TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
app.get("/api/files", requireHttpAuth, async (req, res) => {
|
||||
const requested = typeof req.query.path === "string" ? req.query.path : "";
|
||||
const filePath = await resolveHomeFilePath(requested);
|
||||
if (!filePath) return res.status(403).type("text/plain").send("File is outside the workspace");
|
||||
const stats = await fs.promises.stat(filePath).catch(() => undefined);
|
||||
if (!stats?.isFile()) return res.status(404).type("text/plain").send("File not found");
|
||||
if (req.query.download === "1") return res.download(filePath, path.basename(filePath));
|
||||
if (req.query.inline === "1") {
|
||||
const inlineTypes: Record<string, string> = { ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
||||
const type = inlineTypes[path.extname(filePath).toLowerCase()];
|
||||
if (!type) return res.status(415).type("text/plain").send("File type is not previewable");
|
||||
res.type(type);
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
if (req.query.raw === "1") {
|
||||
if (!canPreviewTextFile(filePath, stats.size)) return res.status(415).type("text/plain").send("File type is not previewable as text");
|
||||
res.type("text/plain");
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
return res.status(400).type("text/plain").send("Use the file preview page");
|
||||
});
|
||||
|
||||
app.use(express.static(clientDist));
|
||||
app.get("/{*path}", (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, "client", "index.html"));
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { summarizeToolResultData } from "../client/tool-result-summary.ts";
|
||||
import { summarizeToolResultContent, summarizeToolResultData } from "../client/tool-result-summary.ts";
|
||||
|
||||
test("summarizes image result content", () => {
|
||||
assert.equal(summarizeToolResultContent([{ type: "image", mimeType: "image/png" }]), "1 image · image/png");
|
||||
assert.equal(summarizeToolResultContent([{ type: "image", mimeType: "image/png" }, { type: "image", mimeType: "image/jpeg" }]), "2 images · image/png, image/jpeg");
|
||||
assert.equal(summarizeToolResultContent([{ type: "text" }]), "");
|
||||
});
|
||||
|
||||
test("summarizes generic result counts and pagination", () => {
|
||||
assert.equal(summarizeToolResultData({ items: [{}, {}], page: { has_more: true } }, false, 20), "2 items · more available");
|
||||
|
||||
Reference in new issue
Block a user