Backport generic tool and preview improvements

This commit is contained in:
udo committed 2026-08-26 12:48:31 +00:00
1 parent 80b9cc1547
commit 83a1049fe2
6 files changed
+236 -66

No files matched your search

+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+11
View File
@@ -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
View File
@@ -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"));
+7 -1
View File
@@ -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");