fix assistant exports and tool calls

This commit is contained in:
ajnart committed 2026-08-10 14:09:44 +02:00
1 parent f7eed7ce8a
commit 305c9af2da
15 files changed
+756 -29

No files matched your search

@@ -0,0 +1,170 @@
import { describe, expect, test } from "vitest";
import {
buildAssistantConversationMarkdown,
buildAssistantMessageMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
describe("buildAssistantConversationMarkdown", () => {
test("exports user, assistant, reasoning, tool, and server telemetry content", () => {
const markdown = buildAssistantConversationMarkdown({
thread: {
id: "thread-12345678",
title: "Icon debugging",
modelId: "deepseek/deepseek-v4-flash",
createdAt: "2026-08-10T08:00:00.000Z",
updatedAt: "2026-08-10T08:01:00.000Z",
},
messages: [
{
id: "user-1",
parentId: null,
format: "ai-sdk/v6",
content: { role: "user", parts: [{ type: "text", text: "Find the Homarr icon" }] },
},
{
id: "assistant-1",
parentId: "user-1",
format: "ai-sdk/v6",
content: {
role: "assistant",
parts: [
{ type: "reasoning", text: "I should search the local icon catalog." },
{
type: "dynamic-tool",
toolName: "icon_findIcons",
toolCallId: "tool-1",
state: "output-available",
input: { searchText: "homarr", apiKey: "must-not-leak" },
output: {
icons: [{ name: "homarr.svg" }],
url: "https://user:password@example.com/icons?api_key=must-not-leak&query=homarr",
},
},
{ type: "text", text: "I found the icon." },
],
metadata: {
custom: {
telemetry: { requestId: "request-1", inputTokens: 120, generationAccessToken: "must-not-leak" },
},
},
},
},
],
exportedAt: new Date("2026-08-10T08:02:00.000Z"),
});
expect(markdown).toContain("## 1. User message");
expect(markdown).toContain("Find the Homarr icon");
expect(markdown).toContain("## 2. Assistant response");
expect(markdown).toContain("<summary>Reasoning</summary>");
expect(markdown).toContain("### Tool call: `icon_findIcons`");
expect(markdown).toContain("- Tool calls: 1");
expect(markdown).toContain('"searchText": "homarr"');
expect(markdown).toContain('"inputTokens": 120');
expect(markdown).toContain('"generationAccessToken": "[redacted]"');
expect(markdown).toContain("https://redacted:redacted@example.com/icons?api_key=redacted&query=homarr");
expect(markdown).not.toContain("must-not-leak");
});
test("normalizes assistant-ui tool calls with raw streamed input, results, errors, and server metadata", () => {
const markdown = buildAssistantMessageMarkdown(
{
id: "assistant-runtime-1",
parentId: "user-1",
format: "assistant-ui/runtime",
content: {
id: "assistant-runtime-1",
role: "assistant",
createdAt: "2026-08-10T08:03:00.000Z",
status: { type: "incomplete", reason: "error" },
content: [
{
type: "tool-call",
toolName: "icon_findIcons",
toolCallId: "icon-call-1",
args: { searchText: "homarr" },
argsText: '{"searchText":"homarr"',
result: { error: "Error in input stream" },
isError: true,
providerMetadata: { openrouter: { traceId: "trace-1" } },
},
],
metadata: { custom: { telemetry: { requestId: "request-2", cost: 0.001 } } },
},
},
new Date("2026-08-10T08:04:00.000Z"),
);
expect(markdown).toContain("### Tool call: `icon_findIcons`");
expect(markdown).toContain('"searchText": "homarr"');
expect(markdown).toContain("Raw streamed input");
expect(markdown).toContain("Error in input stream");
expect(markdown).toContain("Tool diagnostics");
expect(markdown).toContain('"traceId": "trace-1"');
expect(markdown).toContain("Server metadata");
expect(markdown).toContain('"requestId": "request-2"');
});
test("keeps system and unknown server messages available for debugging", () => {
const markdown = buildAssistantConversationMarkdown({
thread: { id: "thread-server", title: "Server records" },
messages: [
{
id: "system-1",
parentId: null,
format: "ai-sdk/v6",
content: { role: "system", parts: [{ type: "text", text: "System context snapshot" }] },
},
{
id: "server-1",
parentId: "system-1",
format: "server/debug",
content: { event: "stream-error", error: "Provider disconnected" },
},
],
});
expect(markdown).toContain("## 1. System message");
expect(markdown).toContain("System context snapshot");
expect(markdown).toContain("## 2. Server message");
expect(markdown).toContain('"event": "stream-error"');
expect(markdown).toContain("Provider disconnected");
});
test("omits embedded attachment data and handles an empty conversation", () => {
expect(
buildAssistantConversationMarkdown({
thread: { id: "thread-empty", title: null },
messages: [],
exportedAt: new Date("2026-08-10T08:02:00.000Z"),
}),
).toContain("No messages have been stored");
const markdown = buildAssistantConversationMarkdown({
thread: { id: "thread-files", title: "Files" },
messages: [
{
id: "user-file",
parentId: null,
format: "ai-sdk/v6",
content: {
role: "user",
parts: [{ type: "data-debug", payload: "data:image/png;base64,AAAA" }],
},
},
],
});
expect(markdown).toContain("[image/png data omitted]");
expect(markdown).not.toContain("base64,AAAA");
});
});
describe("getAssistantConversationExportFilename", () => {
test("creates a short portable Markdown filename", () => {
expect(getAssistantConversationExportFilename(" Icônes & Debug ", "abcdefgh12345678")).toBe(
"icones-debug-abcdefgh.md",
);
});
});
@@ -0,0 +1,260 @@
export interface AssistantConversationExportMessage {
id: string;
parentId: string | null;
format: string;
content: unknown;
createdAt?: Date | string | null;
}
interface AssistantConversationExportInput {
thread: {
id: string;
title?: string | null;
modelId?: string | null;
createdAt?: Date | string | null;
updatedAt?: Date | string | null;
};
messages: AssistantConversationExportMessage[];
exportedAt?: Date;
}
const sensitiveKeyPattern =
/(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|session[-_]?token|(?:access|auth|refresh|generation)[-_]?token|^token$)$/iu;
const dataUrlPattern = /^data:([^;,]+)[;,]/iu;
const bearerTokenPattern = /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/giu;
const commonApiKeyPattern = /\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/giu;
const maxDebugStringLength = 12_000;
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
const redactDebugValue = (value: unknown, key?: string): unknown => {
if (key && sensitiveKeyPattern.test(key)) return "[redacted]";
if (typeof value === "string") {
let text = value.replaceAll(bearerTokenPattern, "Bearer [redacted]").replaceAll(commonApiKeyPattern, "[redacted]");
const dataUrl = dataUrlPattern.exec(text);
if (dataUrl) return `[${dataUrl[1] ?? "binary"} data omitted]`;
if (/^https?:\/\//iu.test(text)) {
try {
const url = new URL(text);
if (url.username) url.username = "redacted";
if (url.password) url.password = "redacted";
for (const name of url.searchParams.keys()) {
if (sensitiveKeyPattern.test(name)) url.searchParams.set(name, "redacted");
}
text = url.toString();
} catch {
// Keep malformed URLs as ordinary bounded debug strings.
}
}
return text.length > maxDebugStringLength
? `${text.slice(0, maxDebugStringLength)}\n… [truncated ${text.length - maxDebugStringLength} characters]`
: text;
}
if (Array.isArray(value)) return value.map((entry) => redactDebugValue(entry));
const record = asRecord(value);
if (!record) return value;
return Object.fromEntries(
Object.entries(record).map(([entryKey, entry]) => [entryKey, redactDebugValue(entry, entryKey)]),
);
};
const fencedJson = (value: unknown) => {
const serialized = JSON.stringify(redactDebugValue(value), null, 2) ?? String(redactDebugValue(value));
return `\n\`\`\`json\n${serialized}\n\`\`\`\n`;
};
const formatDate = (value: Date | string | null | undefined) => {
if (!value) return "Not recorded";
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
};
const roleLabel = (role: unknown) => {
switch (role) {
case "user":
return "User message";
case "assistant":
return "Assistant response";
case "system":
return "System message";
case "tool":
return "Tool message";
default:
return "Server message";
}
};
const getToolName = (part: Record<string, unknown>) => {
if (typeof part.toolName === "string") return part.toolName;
if (typeof part.type !== "string") return null;
if (part.type === "tool-call") return "unknown tool";
return part.type.startsWith("tool-") ? part.type.slice("tool-".length) : null;
};
const isToolPart = (part: unknown) => {
const record = asRecord(part);
if (!record || typeof record.type !== "string") return false;
return record.type === "dynamic-tool" || record.type === "tool-call" || record.type.startsWith("tool-");
};
const getToolValue = (part: Record<string, unknown>, primary: string, fallback: string) =>
primary in part ? part[primary] : part[fallback];
const renderToolPart = (part: Record<string, unknown>) => {
const toolName = getToolName(part) ?? "unknown tool";
const input = getToolValue(part, "input", "args");
const output = getToolValue(part, "output", "result");
const rawInput = getToolValue(part, "rawInput", "argsText");
const state =
typeof part.state === "string"
? part.state
: part.isError === true
? "output-error"
: output !== undefined
? "output-available"
: "input-available";
const lines = [`### Tool call: \`${toolName}\``, `- State: \`${state}\``];
if (typeof part.toolCallId === "string") lines.push(`- Call ID: \`${part.toolCallId}\``);
if (typeof part.providerExecuted === "boolean")
lines.push(`- Provider executed: ${part.providerExecuted ? "Yes" : "No"}`);
if (typeof part.preliminary === "boolean") lines.push(`- Preliminary result: ${part.preliminary ? "Yes" : "No"}`);
if (input !== undefined) lines.push("#### Input", fencedJson(input));
if (rawInput !== undefined && rawInput !== input) lines.push("#### Raw streamed input", fencedJson(rawInput));
if (output !== undefined) lines.push("#### Output", fencedJson(output));
if (typeof part.errorText === "string") lines.push("#### Error", part.errorText);
else if (part.isError === true) lines.push("#### Error", "The tool result was marked as an error.");
if (part.approval !== undefined) lines.push("#### Approval", fencedJson(part.approval));
const diagnostics = Object.fromEntries(
Object.entries(part).filter(
([key]) =>
![
"type",
"toolName",
"toolCallId",
"state",
"input",
"args",
"output",
"result",
"rawInput",
"argsText",
"errorText",
"isError",
"approval",
"providerExecuted",
"preliminary",
].includes(key),
),
);
if (Object.keys(diagnostics).length > 0) {
lines.push("<details>", "<summary>Tool diagnostics</summary>", fencedJson(diagnostics), "</details>");
}
return lines.join("\n");
};
const renderSourcePart = (part: Record<string, unknown>) => {
const title = typeof part.title === "string" ? part.title : "Source";
if (typeof part.url === "string") return `- [${title}](${part.url})`;
return `### Source document\n${fencedJson(part)}`;
};
const renderMessagePart = (part: unknown, index: number) => {
const record = asRecord(part);
if (!record) return `### Part ${index}\n${fencedJson(part)}`;
const type = typeof record.type === "string" ? record.type : "unknown";
if (type === "text" && typeof record.text === "string") return record.text;
if (type === "reasoning" && typeof record.text === "string") {
return `<details>\n<summary>Reasoning</summary>\n\n${record.text}\n\n</details>`;
}
if (type === "step-start") return "**Agent step started**";
if (isToolPart(record)) return renderToolPart(record);
if (type === "source" || type === "source-url" || type === "source-document") return renderSourcePart(record);
if (type === "file" || type === "image" || type === "audio" || type === "reasoning-file") {
return `### ${type}\n${fencedJson(record)}`;
}
if (type === "data" || type.startsWith("data-") || type === "generative-ui" || type === "custom") {
return `### ${type}\n${fencedJson(record)}`;
}
return `### ${type}\n${fencedJson(record)}`;
};
const getMessagePayload = (message: AssistantConversationExportMessage) => asRecord(message.content);
const getMessageParts = (message: AssistantConversationExportMessage) => {
const payload = getMessagePayload(message);
if (Array.isArray(payload?.parts)) return payload.parts;
return Array.isArray(payload?.content) ? payload.content : [];
};
const getMessageToolCount = (message: AssistantConversationExportMessage) =>
getMessageParts(message).filter(isToolPart).length;
const renderMessage = (message: AssistantConversationExportMessage, index: number) => {
const payload = getMessagePayload(message);
const parts = getMessageParts(message);
const createdAt = message.createdAt ?? payload?.createdAt;
const lines = [
`## ${index + 1}. ${roleLabel(payload?.role)}`,
`- Message ID: \`${message.id}\``,
`- Parent ID: ${message.parentId ? `\`${message.parentId}\`` : "None"}`,
`- Storage format: \`${message.format}\``,
`- Created: ${formatDate(createdAt as Date | string | null | undefined)}`,
`- Tool calls: ${getMessageToolCount(message)}`,
];
if (payload?.status !== undefined) lines.push(`- Status: \`${JSON.stringify(redactDebugValue(payload.status))}\``);
if (parts.length === 0) lines.push("", "_No normalized message parts were stored._", fencedJson(message.content));
else lines.push("", ...parts.map((part, partIndex) => renderMessagePart(part, partIndex + 1)));
if (payload?.attachments !== undefined) {
lines.push("", "<details>", "<summary>Attachments</summary>", fencedJson(payload.attachments), "</details>");
}
if (payload?.metadata !== undefined) {
lines.push("", "<details>", "<summary>Server metadata</summary>", fencedJson(payload.metadata), "</details>");
}
return lines.join("\n");
};
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
};
export const buildAssistantConversationMarkdown = ({
thread,
messages,
exportedAt = new Date(),
}: AssistantConversationExportInput) => {
const title = thread.title?.trim() || "Untitled assistant conversation";
const toolCount = messages.reduce((total, message) => total + getMessageToolCount(message), 0);
const header = [
`# ${title}`,
"",
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"",
`- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
`- Created: ${formatDate(thread.createdAt)}`,
`- Updated: ${formatDate(thread.updatedAt)}`,
`- Exported: ${exportedAt.toISOString()}`,
`- Stored messages: ${messages.length}`,
`- Tool calls: ${toolCount}`,
];
if (messages.length === 0) return `${header.join("\n")}\n\n_No messages have been stored for this conversation._\n`;
return `${header.join("\n")}\n\n${messages.map(renderMessage).join("\n\n---\n\n")}\n`;
};
export const getAssistantConversationExportFilename = (title: string | null | undefined, threadId: string) => {
const slug = (title?.trim() || "assistant-conversation")
.normalize("NFKD")
.replaceAll(/\p{Mark}+/gu, "")
.replaceAll(/[^\p{Letter}\p{Number}]+/gu, "-")
.replaceAll(/^-+|-+$/gu, "")
.slice(0, 64)
.toLowerCase();
return `${slug || "assistant-conversation"}-${threadId.slice(0, 8)}.md`;
};
@@ -114,7 +114,7 @@ import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { clientApi, fetchApi } from "@homarr/api/client";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -123,6 +123,11 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import {
buildAssistantConversationMarkdown,
buildAssistantMessageMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
@@ -1068,6 +1073,46 @@ const BranchPicker = () => {
);
};
const downloadAssistantMarkdown = (markdown: string, filename: string) => {
const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
};
const AssistantMessageExportAction = () => {
const t = useScopedI18n("common.assistant");
const message = useAuiState((state) => state.message);
return (
<Tooltip label={t("exportMarkdown")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("exportMarkdown")}
onClick={() => {
const markdown = buildAssistantMessageMarkdown({
id: message.id,
parentId: message.parentId,
format: "assistant-ui/runtime",
content: message,
createdAt: message.createdAt,
});
downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`);
}}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
);
};
const AssistantMessageActions = () => {
const t = useScopedI18n("common.assistant");
return (
@@ -1096,13 +1141,7 @@ const AssistantMessageActions = () => {
</ActionIcon>
</ActionBarPrimitive.FeedbackNegative>
</Tooltip>
<Tooltip label={t("exportMarkdown")}>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("exportMarkdown")}>
<IconFileExport size={14} />
</ActionIcon>
</ActionBarPrimitive.ExportMarkdown>
</Tooltip>
<AssistantMessageExportAction />
<Tooltip label={t("regenerate")}>
<ActionBarPrimitive.Reload asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
@@ -1892,6 +1931,30 @@ const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useScopedI18n("common.assistant");
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false);
const exportConversation = async () => {
if (!remoteId || exporting) return;
setExporting(true);
try {
const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId });
const markdown = buildAssistantConversationMarkdown(conversation);
downloadAssistantMarkdown(
markdown,
getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId),
);
} catch {
showErrorNotification({
title: t("exportConversation.failedTitle"),
message: t("exportConversation.failedDescription"),
});
} finally {
setExporting(false);
}
};
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<Group gap={4} wrap="nowrap">
@@ -1908,6 +1971,20 @@ const ThreadListItem = () => {
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Button>
</ThreadListItemPrimitive.Trigger>
<Tooltip label={t("exportConversation.action")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()}
>
<IconFileExport size={14} />
</ActionIcon>
</Tooltip>
<ThreadListItemPrimitive.Delete asChild>
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
<IconTrash size={14} />