fix assistant exports and tool calls
This commit is contained in:
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} />
|
||||
|
||||
Reference in new issue
Block a user