From 83a1049fe2e817d41c2e5dc246ecbf84a00d1b6f Mon Sep 17 00:00:00 2001 From: udo Date: Wed, 26 Aug 2026 12:45:36 +0000 Subject: [PATCH] Backport generic tool and preview improvements --- README.md | 5 +- client/app.css | 44 ++++++- client/main.ts | 186 +++++++++++++++++++++--------- client/tool-result-summary.ts | 11 ++ server/index.ts | 48 +++++++- tests/tool-result-summary.test.ts | 8 +- 6 files changed, 236 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 84d7426..68d1b32 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/client/app.css b/client/app.css index ec6932a..96aee88 100644 --- a/client/app.css +++ b/client/app.css @@ -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; } diff --git a/client/main.ts b/client/main.ts index 8770f1d..d725727 100644 --- a/client/main.ts +++ b/client/main.ts @@ -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`Open ${filePath}`; +} + 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(); -const autoCollapsedToolIds = new Set(); -const seenToolCallIds = new Set(); -let activeToolCallId = ""; -type ToolProgressiveMode = "input" | "result" | "full" | "collapsed"; +type ToolProgressiveMode = "full" | "collapsed"; function toolArgsObject(args: any): Record { 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`
@@ -156,20 +143,14 @@ function renderProgressiveToolMessage(toolCall: any, result: ToolResultMessage | ${resultSummary || status} - ${mode === "collapsed" ? nothing : html` + ${!showDetails ? nothing : html`
- ${showInput ? html` -
- - -
- ` : effectiveResult ? html` -
- Input - -
- ` : nothing} - ${showResult ? html` + ${["read", "write", "edit"].includes(toolCall.name) && typeof args.path === "string" && args.path.trim() ? renderWorkspaceFileLink(args.path) : nothing} +
+ + +
+ ${effectiveResult ? html`
@@ -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` +
+

${title}

+ ${state === "ready" && !inlinePreview ? html`` : nothing} + +
+
${pathParam}
+
+ ${pdfPreview && inlineSource ? html`` : imagePreview && inlineSource ? html`${title}` : state === "loading" ? html`

Loading...

` : state === "error" ? html`

${error}

` : html` +
+ ${isMarkdownPath(pathParam) ? html`` : nothing} + + ${copyState === "error" ? html`Could not copy the file text.` : nothing} +
+
${isMarkdownPath(pathParam) && mode === "rendered" ? html`
` : html`
${content}
`}
+ `} +
+
+ `, 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(); diff --git a/client/tool-result-summary.ts b/client/tool-result-summary.ts index 1506922..5214e60 100644 --- a/client/tool-result-summary.ts +++ b/client/tool-result-summary.ts @@ -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; diff --git a/server/index.ts b/server/index.ts index ff36a27..5813535 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 { + 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 = { ".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")); diff --git a/tests/tool-result-summary.test.ts b/tests/tool-result-summary.test.ts index dca1f3b..ebef9c1 100644 --- a/tests/tool-result-summary.test.ts +++ b/tests/tool-result-summary.test.ts @@ -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");