fix(ui): cap tool output + sync config form
This commit is contained in:
@@ -51,6 +51,9 @@
|
||||
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
|
||||
- Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385.
|
||||
- Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440)
|
||||
- Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export.
|
||||
- Web UI: cap tool output + large markdown rendering to avoid UI freezes on huge tool results.
|
||||
- Web UI: keep config form edits synced to raw JSON so form saves persist.
|
||||
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
|
||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ResolvedTheme,
|
||||
type ThemeMode,
|
||||
} from "./theme";
|
||||
import { truncateText } from "./format";
|
||||
import {
|
||||
startThemeTransition,
|
||||
type ThemeTransitionContext,
|
||||
@@ -88,6 +89,7 @@ type EventLogEntry = {
|
||||
};
|
||||
|
||||
const TOOL_STREAM_LIMIT = 50;
|
||||
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
|
||||
const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||
trace: true,
|
||||
debug: true,
|
||||
@@ -138,17 +140,25 @@ function extractToolOutputText(value: unknown): string | null {
|
||||
|
||||
function formatToolOutput(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
const contentText = extractToolOutputText(value);
|
||||
if (contentText) return contentText;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value;
|
||||
} else if (contentText) {
|
||||
text = contentText;
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
|
||||
if (!truncated.truncated) return truncated.text;
|
||||
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -145,6 +145,25 @@ describe("applyConfigSnapshot", () => {
|
||||
expect(state.slackForm.botToken).toBe("");
|
||||
expect(state.slackForm.actions).toEqual(defaultSlackActions);
|
||||
});
|
||||
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormMode = "form";
|
||||
state.configFormDirty = true;
|
||||
state.configForm = { gateway: { mode: "local", port: 18789 } };
|
||||
state.configRaw = "{\n}\n";
|
||||
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "remote", port: 9999 } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
|
||||
});
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
@@ -165,6 +184,22 @@ describe("updateConfigFormValue", () => {
|
||||
gateway: { mode: "local", port: 18789 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw in sync while editing the form", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{\n}\n",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyConfig", () => {
|
||||
|
||||
@@ -92,10 +92,18 @@ export function applyConfigSchema(
|
||||
|
||||
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
|
||||
state.configSnapshot = snapshot;
|
||||
if (typeof snapshot.raw === "string") {
|
||||
state.configRaw = snapshot.raw;
|
||||
} else if (snapshot.config && typeof snapshot.config === "object") {
|
||||
state.configRaw = `${JSON.stringify(snapshot.config, null, 2).trimEnd()}\n`;
|
||||
const rawFromSnapshot =
|
||||
typeof snapshot.raw === "string"
|
||||
? snapshot.raw
|
||||
: snapshot.config && typeof snapshot.config === "object"
|
||||
? serializeConfigForm(snapshot.config as Record<string, unknown>)
|
||||
: state.configRaw;
|
||||
if (!state.configFormDirty || state.configFormMode === "raw") {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
} else if (state.configForm) {
|
||||
state.configRaw = serializeConfigForm(state.configForm);
|
||||
} else {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
}
|
||||
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
|
||||
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
||||
@@ -388,7 +396,7 @@ export async function saveConfig(state: ConfigState) {
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
await state.client.request("config.set", { raw });
|
||||
state.configFormDirty = false;
|
||||
@@ -407,7 +415,7 @@ export async function applyConfig(state: ConfigState) {
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
@@ -448,6 +456,9 @@ export function updateConfigFormValue(
|
||||
setPathValue(base, path, value);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeConfigFormValue(
|
||||
@@ -460,6 +471,9 @@ export function removeConfigFormValue(
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneConfigObject<T>(value: T): T {
|
||||
@@ -469,6 +483,10 @@ function cloneConfigObject<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
|
||||
@@ -40,6 +40,21 @@ export function clampText(value: string, max = 120): string {
|
||||
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
export function truncateText(value: string, max: number): {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
} {
|
||||
if (value.length <= max) {
|
||||
return { text: value, truncated: false, total: value.length };
|
||||
}
|
||||
return {
|
||||
text: value.slice(0, Math.max(0, max)),
|
||||
truncated: true,
|
||||
total: value.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function toNumber(value: string, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
@@ -51,4 +66,3 @@ export function parseList(input: string): string[] {
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { truncateText } from "./format";
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
@@ -39,6 +40,8 @@ const allowedTags = [
|
||||
const allowedAttrs = ["class", "href", "rel", "target", "title"];
|
||||
|
||||
let hooksInstalled = false;
|
||||
const MARKDOWN_CHAR_LIMIT = 140_000;
|
||||
const MARKDOWN_PARSE_LIMIT = 40_000;
|
||||
|
||||
function installHooks() {
|
||||
if (hooksInstalled) return;
|
||||
@@ -57,10 +60,30 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
const input = markdown.trim();
|
||||
if (!input) return "";
|
||||
installHooks();
|
||||
const rendered = marked.parse(input) as string;
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
: "";
|
||||
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
|
||||
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
||||
const html = `<pre class="code-block">${escaped}</pre>`;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
}
|
||||
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
|
||||
return DOMPurify.sanitize(rendered, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user