- ` : nothing}
- ${showResult ? html`
+ ${["read", "write", "edit"].includes(toolCall.name) && typeof args.path === "string" && args.path.trim() ? renderWorkspaceFileLink(args.path) : nothing}
+
Result
@@ -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`
+
+
+
${pathParam}
+
+ ${pdfPreview && inlineSource ? html`` : imagePreview && inlineSource ? html`
` : 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");