diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
index 170d90f8..443a750b 100644
--- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
+++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs
@@ -732,7 +732,13 @@ public partial class ContentBlockComponent : MSGComponentBase
// here which would fall out of sync with the one in FileExportFormatExtensions.
//
if (format.UsesPandoc())
- await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
+ {
+ if (this.Content.TryGetMarkdownText(out var originalMarkdown) &&
+ PlainFileExport.TryExtractStandaloneSource(originalMarkdown, format, out var source))
+ await PlainFileExport.ToSourceFile(this.RustService, this.EffectiveExportTitle, format, source);
+ else
+ await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
+ }
else if (this.Content.TryGetExportMarkdown(out var markdown))
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
}
@@ -852,4 +858,4 @@ public partial class ContentBlockComponent : MSGComponentBase
await this.DisposeMathContainerIfNeededAsync();
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs
index d3e56cad..e1edc61e 100644
--- a/app/MindWork AI Studio/Tools/PlainFileExport.cs
+++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs
@@ -1,6 +1,7 @@
using System.Text;
using AIStudio.Tools.PluginSystem;
+using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Markdig.Extensions.Tables;
@@ -15,6 +16,42 @@ public static class PlainFileExport
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport));
+ ///
+ /// Reads a complete HTML or LaTeX source file out of a message which consists only of the
+ /// matching fenced code block.
+ ///
+ /// The Markdown text the model wrote.
+ /// The source format to extract.
+ /// The contents inside the fence, or an empty string when the message is
+ /// not a standalone source file.
+ /// True, when the message is a complete source file for the selected format.
+ internal static bool TryExtractStandaloneSource(string markdown, FileExportFormat format, out string source)
+ {
+ source = string.Empty;
+
+ var languages = format switch
+ {
+ FileExportFormat.HTML => new[] { "html" },
+ FileExportFormat.LATEX => new[] { "latex", "tex" },
+
+ _ => [],
+ };
+
+ if (languages.Length is 0 || string.IsNullOrWhiteSpace(markdown))
+ return false;
+
+ var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE);
+ if (document.Count is not 1 || document[0] is not FencedCodeBlock block || block.ClosingFencedCharCount is 0)
+ return false;
+
+ var language = block.Info?.Trim();
+ if (!languages.Any(candidate => string.Equals(candidate, language, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ source = block.Lines.ToString();
+ return true;
+ }
+
///
/// Reads every table a message holds, in the order they appear in it.
///
@@ -183,6 +220,30 @@ public static class PlainFileExport
if (format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter)
throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself.");
+ return await WriteFile(rustService, dialogTitle, format, fileContent, fileTypeFilter, fileName);
+ }
+
+ ///
+ /// Writes HTML or LaTeX source authored by the model without converting it.
+ ///
+ /// The Rust service, used for the save dialog.
+ /// The title of the save dialog.
+ /// The matching HTML or LaTeX format.
+ /// The source text inside the model's code fence.
+ /// True, when the file was written.
+ internal static async Task ToSourceFile(RustService rustService, string dialogTitle, FileExportFormat format, string source)
+ {
+ if (format is not (FileExportFormat.HTML or FileExportFormat.LATEX) || format.ToFileTypeFilter() is not { } fileTypeFilter)
+ throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format as an authored source file.");
+
+ return await WriteFile(rustService, dialogTitle, format, source, fileTypeFilter);
+ }
+
+ ///
+ /// Lets the user choose a path and writes text with the encoding of its format.
+ ///
+ private static async Task WriteFile(RustService rustService, string dialogTitle, FileExportFormat format, string fileContent, FileTypeFilter fileTypeFilter, string? fileName = null)
+ {
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
if (response.UserCancelled)
{
@@ -206,4 +267,4 @@ public static class PlainFileExport
return false;
}
}
-}
\ No newline at end of file
+}
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
index e0d15144..918700e9 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md
@@ -64,6 +64,7 @@
- Fixed an assistant refusing to work after you switched on the cleanup of web content without having chosen a model first. Its button did nothing at all, and only closing the assistant and starting over helped. The cleanup now follows the model of the assistant you are working in, the moment you pick one.
- Fixed the Visual Briefing assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should.
- Fixed exported answers losing their sources. When an answer is based on web pages a tool read or on documents of your own, the exported file now lists those sources in every format AI Studio writes.
+- Fixed exporting an answer made only of one complete HTML or LaTeX code block. AI Studio now saves the source directly, without Markdown fences or a Pandoc conversion.
- Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along.
- Fixed a tile that opens a chat directly always demanding a workspace. Leave the workspace empty in the Assistant Builder, and the tile opens a disappearing chat instead.
- Fixed the same restriction for plugin authors: a direct-chat launcher can now open a chat without naming a workspace. The example assistant plugin shows both ways.