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

+8 -3
View File
@@ -117,7 +117,10 @@ page layout until it is opened. Users can start, revisit, and delete conversatio
conversation menu in the floating panel.
Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export,
and helpful/not-helpful feedback.
and helpful/not-helpful feedback. The conversation menu also provides a compact export action beside delete for
each conversation. Its debugging-oriented Markdown includes every stored user, assistant, reasoning, tool, error,
branch-parent, and server-metadata record while redacting secret-looking fields and embedded attachment data.
The per-response export uses the same tool-aware format instead of assistant-ui's text-only copy representation.
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
Files returned by a compatible model can be downloaded directly from the message.
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
@@ -200,8 +203,10 @@ file formats, and repositories instead of the size of the complete icon catalog.
activity such as **Looking for “homarr” icons** instead of exposing the internal MCP procedure name.
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent
read-only lookups may be performed together, while every mutation still pauses for its normal approval.
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. OpenRouter calls
Homarr tools sequentially to prevent routed models from interleaving or truncating parallel JSON arguments, while
every mutation still pauses for its normal approval. A safely recoverable truncated icon-search object is repaired
before validation; incomplete search strings and all mutation inputs are never guessed.
The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with
headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows
@@ -4,6 +4,7 @@ import {
getOpenRouterWebSearchRequests,
getOpenRouterWebSearchSources,
normalizeOpenRouterWebSearchSources,
withOpenRouterToolRequestOptions,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
@@ -28,6 +29,21 @@ describe("withOpenRouterWebSearch", () => {
expect(withOpenRouterWebSearch(body)).toBe(body);
});
test("runs OpenRouter function tools sequentially while preserving optional server web search", () => {
expect(
withOpenRouterToolRequestOptions(
{
model: "deepseek/deepseek-v4-flash-latest",
tools: [{ type: "function", function: { name: "icon_findIcons", parameters: {} } }],
},
{ webSearchEnabled: true },
),
).toMatchObject({
parallel_tool_calls: false,
tools: [{ type: "function", function: { name: "icon_findIcons" } }, { type: "openrouter:web_search" }],
});
});
test("reads OpenRouter server-tool usage from a response", () => {
expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: 2 } } })).toBe(2);
expect(getOpenRouterWebSearchRequests({ usage: {} })).toBeUndefined();
@@ -82,6 +82,17 @@ export const withOpenRouterWebSearch = (body: Record<string, unknown>) => {
return { ...body, tools: [...tools, openRouterWebSearchTool] };
};
export const withOpenRouterToolRequestOptions = (
body: Record<string, unknown>,
options: { webSearchEnabled: boolean },
) => ({
...(options.webSearchEnabled ? withOpenRouterWebSearch(body) : body),
// OpenRouter enables parallel tool calls for most models by default. Several routed models
// interleave or truncate function argument streams when they emit multiple calls together.
// Sequential calls preserve the same agent loop while making every tool input independently valid.
parallel_tool_calls: false,
});
export const getOpenRouterWebSearchRequests = (value: unknown) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const usage = "usage" in value && typeof value.usage === "object" && value.usage !== null ? value.usage : undefined;
@@ -1,13 +1,13 @@
import { describe, expect, test } from "vitest";
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
describe("repairCustomWidgetToolInput", () => {
describe("repairAssistantToolInput", () => {
test("repairs literal newlines generated inside a multiline Custom JSX template", () => {
const input = `{"definition":{"template":"<Stack>
<Text>{data.fixtures?.length} fixtures</Text>
</Stack>"}}`;
const repaired = repairCustomWidgetToolInput({ toolName: "customWidget_previewCreate", input });
const repaired = repairAssistantToolInput({ toolName: "customWidget_previewCreate", input });
expect(repaired).not.toBeNull();
expect(JSON.parse(repaired?.input ?? "")).toEqual({
@@ -16,7 +16,7 @@ describe("repairCustomWidgetToolInput", () => {
});
test("repairs every JSON-forbidden control character inside a Custom Widget string", () => {
const repaired = repairCustomWidgetToolInput({
const repaired = repairAssistantToolInput({
toolName: "customWidget_validate",
input: '{"value":"before\u0000\b\fafter"}',
});
@@ -25,12 +25,28 @@ describe("repairCustomWidgetToolInput", () => {
});
test("does not alter unrelated tools or guess at truncated JSON", () => {
expect(repairCustomWidgetToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
expect(repairAssistantToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
expect(
repairCustomWidgetToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
repairAssistantToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
).toBeNull();
});
test("recovers a truncated read-only icon search after the model omits the closing object delimiter", () => {
expect(
repairAssistantToolInput({
toolName: "icon_findIcons",
input: '{"searchText":"homarr","limitPerGroup":6',
}),
).toEqual({
toolName: "icon_findIcons",
input: '{"searchText":"homarr","limitPerGroup":6}',
});
});
test("does not guess icon search text when the streamed string itself is incomplete", () => {
expect(repairAssistantToolInput({ toolName: "icon_findIcons", input: '{"searchText":"homar' })).toBeNull();
});
test.each([
"customWidget_getSkill",
"customWidget_schema",
@@ -38,10 +54,10 @@ describe("repairCustomWidgetToolInput", () => {
"customWidget_getComponentCatalog",
"customWidget_list",
])("normalizes malformed or provider-specific no-input arguments for %s", (toolName) => {
expect(repairCustomWidgetToolInput({ toolName, input: "null" })?.input).toBe("{}");
expect(repairCustomWidgetToolInput({ toolName, input: "[]" })?.input).toBe("{}");
expect(repairCustomWidgetToolInput({ toolName, input: '{"unexpected":true}' })?.input).toBe("{}");
expect(repairCustomWidgetToolInput({ toolName, input: '"' })?.input).toBe("{}");
expect(repairCustomWidgetToolInput({ toolName, input: "{}" })).toBeNull();
expect(repairAssistantToolInput({ toolName, input: "null" })?.input).toBe("{}");
expect(repairAssistantToolInput({ toolName, input: "[]" })?.input).toBe("{}");
expect(repairAssistantToolInput({ toolName, input: '{"unexpected":true}' })?.input).toBe("{}");
expect(repairAssistantToolInput({ toolName, input: '"' })?.input).toBe("{}");
expect(repairAssistantToolInput({ toolName, input: "{}" })).toBeNull();
});
});
@@ -60,7 +60,36 @@ const escapeControlCharactersInsideJsonStrings = (value: string) => {
return result;
};
export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (toolCall.toolName !== "icon_findIcons") return null;
// Some OpenAI-compatible providers finish a streamed tool call after the last value but before
// the closing object delimiter. Recover only the harmless, read-only icon search fields instead
// of trying to balance arbitrary mutation input.
const searchTextMatch = /"searchText"\s*:\s*("(?:\\.|[^"\\])*")/u.exec(toolCall.input);
if (!searchTextMatch?.[1]) return null;
let searchText: unknown;
try {
searchText = JSON.parse(searchTextMatch[1]);
} catch {
return null;
}
if (typeof searchText !== "string") return null;
const limitMatch = /"limitPerGroup"\s*:\s*(\d+)/u.exec(toolCall.input);
const limitPerGroup = limitMatch?.[1] ? Number(limitMatch[1]) : undefined;
const hasValidLimit =
limitPerGroup !== undefined && Number.isInteger(limitPerGroup) && limitPerGroup >= 1 && limitPerGroup <= 500;
return {
...toolCall,
input: JSON.stringify({
searchText,
...(hasValidLimit ? { limitPerGroup } : {}),
}),
};
};
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
@@ -83,3 +112,6 @@ export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(to
return null;
}
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
@@ -2,6 +2,8 @@ import { describe, expect, test, vi } from "vitest";
import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } from "ai";
import { MockLanguageModelV4 } from "ai/test";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
@@ -100,4 +102,69 @@ describe("Assistant tool loop", () => {
expect(nextPrompt).toContain("homarr.svg");
expect(nextPrompt).toContain('"id":"board-1"');
});
test("repairs and executes an icon search whose streamed object is missing its closing delimiter", async () => {
const findIcons = vi.fn(() => ({
countIcons: 1,
icons: [
{
slug: "homarr-labs/dashboard-icons",
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
},
],
}));
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "icon-call",
toolName: "icon_findIcons",
input: '{"searchText":"homarr"',
},
finish("tool-calls"),
],
chunkDelayInMs: null,
}),
},
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{ type: "text-start" as const, id: "answer" },
{ type: "text-delta" as const, id: "answer", delta: "The icon search completed." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Find the Homarr icon.",
tools: {
icon_findIcons: tool({
inputSchema: jsonSchema({
type: "object",
properties: { searchText: { type: "string" } },
additionalProperties: false,
}),
execute: findIcons,
}),
},
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
stopWhen: stepCountIs(2),
});
await expect(result.text).resolves.toBe("The icon search completed.");
expect(findIcons).toHaveBeenCalledWith(
{ searchText: "homarr" },
expect.objectContaining({ toolCallId: "icon-call" }),
);
});
});
@@ -7,7 +7,7 @@ import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import { createCustomWidgetDynamicContextController } from "./custom-widget-authoring-context";
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
@@ -217,7 +217,7 @@ describe("Custom Widget assistant tool loop", () => {
execute,
}),
},
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
stopWhen: stepCountIs(2),
});
@@ -50,13 +50,14 @@ import {
getOpenRouterWebSearchRequests,
getOpenRouterWebSearchSources,
normalizeOpenRouterWebSearchSources,
withOpenRouterToolRequestOptions,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
import { toProviderOptionsKey } from "./assistant-provider-options";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
import {
createCustomWidgetDynamicContextController,
getCustomWidgetAuthoringContext,
@@ -143,7 +144,7 @@ Action rules:
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
- When a Homarr tool returns a usable image or icon URL and a visual summary helps, embed that exact URL with Markdown image syntax. App summary tables must use an Icon column such as ![Discord icon](exact-returned-url). Never replace an available returned icon with emoji, and never invent or transform image URLs.
- Complete batch requests in the same run. Use parallel independent read-only calls when supported, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue".
- Complete batch requests in the same run. Perform independent read-only calls in sequence, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue".
- Browser tools can navigate within Homarr, open existing Homarr UI, or refresh the active view after a completed change. Never navigate to an arbitrary external URL and never refresh before a mutation finishes.
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists.
@@ -571,7 +572,12 @@ export async function POST(request: Request) {
apiKey: providerApiKey,
headers: providerHeaders,
includeUsage: true,
transformRequestBody: openRouterServerToolsEnabled ? withOpenRouterWebSearch : undefined,
transformRequestBody:
configuration.provider === "openrouter"
? (body) => withOpenRouterToolRequestOptions(body, { webSearchEnabled: openRouterServerToolsEnabled })
: openRouterServerToolsEnabled
? withOpenRouterWebSearch
: undefined,
metadataExtractor: createProviderTelemetryExtractor(),
});
@@ -624,7 +630,7 @@ export async function POST(request: Request) {
},
maxOutputTokens: assistantExecutionPolicy.maxOutputTokens,
maxRetries: 2,
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
providerOptions:
configuration.provider === "openrouter" || openRouterServerToolsEnabled
@@ -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} />
+1
View File
@@ -721,6 +721,7 @@ export const assistantRouter = createTRPCRouter({
id: message.id,
parentId: message.parentId,
format: message.format,
createdAt: message.createdAt,
content: parse(message.content),
})),
};
@@ -357,4 +357,42 @@ describe("assistant conversation features", () => {
metadata: { submittedFeedback: { type: "positive" } },
});
});
test("returns complete stored tool messages for conversation debugging exports", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
const content = {
role: "assistant",
parts: [
{
type: "tool-icon_findIcons",
toolCallId: "call-icons",
state: "output-available",
input: { searchText: "homarr" },
output: { icons: [{ name: "homarr.svg" }] },
},
],
metadata: { custom: { telemetry: { requestId: "request-1" } } },
};
await caller.appendMessage({
threadId: thread.id,
id: "message-with-tool",
parentId: null,
format: "ai-sdk/v6",
content,
});
const result = await caller.getThread({ threadId: thread.id });
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toMatchObject({
id: "message-with-tool",
parentId: null,
format: "ai-sdk/v6",
content,
});
expect(result.messages[0]?.createdAt).toBeInstanceOf(Date);
});
});
@@ -51,4 +51,27 @@ describe("findIcons", () => {
expect(result.icons).toMatchObject([{ slug: "example/empty", icons: [] }]);
});
test("finds the Homarr icon and returns its exact local repository URL", async () => {
const db = createDb();
await db.insert(iconRepositories).values({ id: "dashboard-icons", slug: "homarr-labs/dashboard-icons" });
await db.insert(icons).values({
id: "homarr-svg",
name: "homarr.svg",
url: "https://cdn.example.com/homarr.svg",
checksum: "homarr",
iconRepositoryId: "dashboard-icons",
});
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.findIcons({ searchText: "homarr" })).resolves.toMatchObject({
countIcons: 1,
icons: [
{
slug: "homarr-labs/dashboard-icons",
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
},
],
});
});
});
+5
View File
@@ -2087,6 +2087,11 @@
"notHelpful": "Not helpful",
"feedbackError": "Your feedback could not be saved. Try again.",
"exportMarkdown": "Export as Markdown",
"exportConversation": {
"action": "Export conversation as Markdown",
"failedTitle": "Conversation could not be exported",
"failedDescription": "Homarr could not load this conversation. Try again."
},
"reasoning": "Thinking",
"previousResponse": "Previous response",
"nextResponse": "Next response",