Added export to word function for the chat (#566)
Some checks are pending
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Sabrina-devops 2025-11-11 15:30:17 +01:00 committed by GitHub
parent 37bd42e41f
commit 9418b99275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 256 additions and 1 deletions

View File

@ -1363,6 +1363,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove
-- No, keep it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, keep it"
-- Export Chat to Microsoft Word
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
-- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
@ -5350,6 +5353,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T567205144"] = "It seems that Pandoc i
-- The latest Pandoc version was not found, installing version {0} instead.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T726914939"] = "The latest Pandoc version was not found, installing version {0} instead."
-- Error during Microsoft Word export
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T3290596792"] = "Error during Microsoft Word export"
-- Microsoft Word export successful
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T4256043333"] = "Microsoft Word export successful"
-- The table AUTHORS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "The table AUTHORS does not exist or is using an invalid syntax."

View File

@ -2,6 +2,7 @@
@using MudBlazor
@using AIStudio.Components
@inherits AIStudio.Components.MSGComponentBase
<MudCard Class="@this.CardClasses" Outlined="@true">
<MudCardHeader>
<CardHeaderAvatar>
@ -47,6 +48,13 @@
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@this.RemoveBlock"/>
</MudTooltip>
}
@if (this.Role is ChatRole.AI)
{
<MudTooltip Text="@T("Export Chat to Microsoft Word")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@this.ExportToWord"/>
</MudTooltip>
}
<MudCopyClipboardButton Content="@this.Content" Type="@this.Type" Size="Size.Medium"/>
</CardHeaderActions>
</MudCardHeader>

View File

@ -1,5 +1,5 @@
using AIStudio.Components;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Chat;
@ -63,6 +63,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
private bool HideContent { get; set; }
#region Overrides of ComponentBase
@ -133,6 +136,11 @@ public partial class ContentBlockComponent : MSGComponentBase
await this.RemoveBlockFunc(this.Content);
}
private async Task ExportToWord()
{
await PandocExport.ToMicrosoftWord(this.RustService, T("Export Chat to Microsoft Word"), this.Content);
}
private async Task RegenerateBlock()
{
if (this.RegenerateFunc is null)
@ -179,4 +187,5 @@ public partial class ContentBlockComponent : MSGComponentBase
if (edit.HasValue && edit.Value)
await this.EditLastUserBlockFunc(this.Content);
}
}

View File

@ -1365,6 +1365,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachric
-- No, keep it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "Nein, behalten"
-- Export Chat to Microsoft Word
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Chat in Microsoft Word exportieren"
-- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Einstellungen öffnen"
@ -5352,6 +5355,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T567205144"] = "Es scheint, dass Pando
-- The latest Pandoc version was not found, installing version {0} instead.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T726914939"] = "Die neueste Pandoc-Version wurde nicht gefunden, stattdessen wird Version {0} installiert."
-- Error during Microsoft Word export
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T3290596792"] = "Fehler beim Exportieren nach Microsoft Word"
-- Microsoft Word export successful
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T4256043333"] = "Export nach Microsoft Word erfolgreich"
-- The table AUTHORS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "Die Tabelle AUTHORS existiert nicht oder verwendet eine ungültige Syntax."
@ -5753,3 +5762,4 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannt
-- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen"

View File

@ -1365,6 +1365,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove
-- No, keep it
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4188329028"] = "No, keep it"
-- Export Chat to Microsoft Word
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T861873672"] = "Export Chat to Microsoft Word"
-- Open Settings
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
@ -5352,6 +5355,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T567205144"] = "It seems that Pandoc i
-- The latest Pandoc version was not found, installing version {0} instead.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOC::T726914939"] = "The latest Pandoc version was not found, installing version {0} instead."
-- Error during Microsoft Word export
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T3290596792"] = "Error during Microsoft Word export"
-- Microsoft Word export successful
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PANDOCEXPORT::T4256043333"] = "Microsoft Word export successful"
-- The table AUTHORS does not exist or is using an invalid syntax.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINBASE::T1068328139"] = "The table AUTHORS does not exist or is using an invalid syntax."
@ -5753,3 +5762,4 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w
-- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"

View File

@ -0,0 +1,97 @@
using System.Diagnostics;
using AIStudio.Chat;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
namespace AIStudio.Tools;
public static class PandocExport
{
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(PandocExport));
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PandocExport).Namespace, nameof(PandocExport));
public static async Task<bool> ToMicrosoftWord(RustService rustService, string dialogTitle, IContent markdownContent)
{
var response = await rustService.SaveFile(dialogTitle, new("Microsoft Word", ["docx"]));
if (response.UserCancelled)
{
LOGGER.LogInformation("User cancelled the save dialog.");
return false;
}
LOGGER.LogInformation($"The user chose the path '{response.SaveFilePath}' for the Microsoft Word export.");
var tempMarkdownFile = Guid.NewGuid().ToString();
var tempMarkdownFilePath = Path.Combine(Path.GetTempPath(), tempMarkdownFile);
try
{
// Extract text content from chat:
var markdownText = markdownContent switch
{
ContentText text => text.Text,
ContentImage _ => "Image export to Microsoft Word not yet possible",
_ => "Unknown content type. Cannot export to Word."
};
// Write text content to a temporary file:
await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText);
// Ensure that Pandoc is installed and ready:
var pandocState = await Pandoc.CheckAvailabilityAsync(rustService);
if (!pandocState.IsAvailable)
return false;
// Call Pandoc to create the Word file:
var pandoc = await PandocProcessBuilder
.Create()
.UseStandaloneMode()
.WithInputFormat("markdown")
.WithOutputFormat("docx")
.WithOutputFile(response.SaveFilePath)
.WithInputFile(tempMarkdownFilePath)
.BuildAsync(rustService);
using var process = Process.Start(pandoc.StartInfo);
if (process is null)
{
LOGGER.LogError("Failed to start Pandoc process.");
return false;
}
await process.WaitForExitAsync();
if (process.ExitCode is not 0)
{
var error = await process.StandardError.ReadToEndAsync();
LOGGER.LogError($"Pandoc failed with exit code {process.ExitCode}: {error}");
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Error during Microsoft Word export")));
return false;
}
LOGGER.LogInformation("Pandoc conversion successful.");
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("Microsoft Word export successful")));
return true;
}
catch (Exception ex)
{
LOGGER.LogError(ex, "Error during Word export.");
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Error during Microsoft Word export")));
return false;
}
finally
{
// Try to remove the temp file:
try
{
File.Delete(tempMarkdownFilePath);
}
catch
{
LOGGER.LogWarning($"Was not able to delete temporary file: '{tempMarkdownFilePath}'");
}
}
}
}

View File

@ -19,6 +19,7 @@ public sealed class PandocProcessBuilder
private string? providedOutputFile;
private string? providedInputFormat;
private string? providedOutputFormat;
private bool useStandaloneMode;
private readonly List<string> additionalArguments = new();
@ -58,10 +59,19 @@ public sealed class PandocProcessBuilder
return this;
}
public PandocProcessBuilder UseStandaloneMode()
{
this.useStandaloneMode = true;
return this;
}
public async Task<PandocPreparedProcess> BuildAsync(RustService rustService)
{
var sbArguments = new StringBuilder();
if (this.useStandaloneMode)
sbArguments.Append(" --standalone ");
if(!string.IsNullOrWhiteSpace(this.providedInputFile))
sbArguments.Append(this.providedInputFile);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public readonly record struct FileSaveResponse(bool UserCancelled, string SaveFilePath);

View File

@ -0,0 +1,10 @@
namespace AIStudio.Tools.Rust;
public class SaveFileOptions
{
public required string Title { get; init; }
public PreviousFile? PreviousFile { get; init; }
public FileTypeFilter? Filter { get; init; }
}

View File

@ -35,4 +35,31 @@ public sealed partial class RustService
return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
}
/// <summary>
/// Initiates a dialog to let the user select a file for a writing operation.
/// </summary>
/// <param name="title">The title of the save file dialog.</param>
/// <param name="filter">An optional file type filter for filtering specific file formats.</param>
/// <param name="initialFile">An optional initial file path to pre-fill in the dialog.</param>
/// <returns>A <see cref="FileSaveResponse"/> object containing information about whether the user canceled the
/// operation and whether the select operation was successful.</returns>
public async Task<FileSaveResponse> SaveFile(string title, FileTypeFilter? filter = null, string? initialFile = null)
{
var payload = new SaveFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = filter
};
var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a file for writing operation '{result.StatusCode}'");
return new FileSaveResponse(true, string.Empty);
}
return await result.Content.ReadFromJsonAsync<FileSaveResponse>(this.jsonRustSerializerOptions);
}
}

View File

@ -306,6 +306,13 @@ pub struct SelectFileOptions {
filter: Option<FileTypeFilter>,
}
#[derive(Clone, Deserialize)]
pub struct SaveFileOptions {
title: String,
name_file: Option<PreviousFile>,
filter: Option<FileTypeFilter>,
}
#[derive(Serialize)]
pub struct DirectorySelectionResponse {
user_cancelled: bool,
@ -362,6 +369,55 @@ pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<F
}
}
#[post("/save/file", data = "<payload>")]
pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileSaveResponse> {
// Create a new file dialog builder:
let file_dialog = FileDialogBuilder::new();
// Set the title of the file dialog:
let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided:
let file_dialog = match &payload.filter {
Some(filter) => {
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
},
None => file_dialog,
};
// Set the previous file path if provided:
let file_dialog = match &payload.name_file {
Some(previous) => {
let previous_path = previous.file_path.as_str();
file_dialog.set_directory(previous_path)
},
None => file_dialog,
};
// Displays the file dialogue box and select the file:
let file_path = file_dialog.save_file();
match file_path {
Some(path) => {
info!("User selected file for writing operation: {path:?}");
Json(FileSaveResponse {
user_cancelled: false,
save_file_path: path.to_str().unwrap().to_string(),
})
},
None => {
info!("User cancelled file selection.");
Json(FileSaveResponse {
user_cancelled: true,
save_file_path: String::from(""),
})
},
}
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
@ -372,6 +428,11 @@ pub struct FileSelectionResponse {
user_cancelled: bool,
selected_file_path: String,
}
#[derive(Serialize)]
pub struct FileSaveResponse {
user_cancelled: bool,
save_file_path: String,
}
fn set_pdfium_path(path_resolver: PathResolver) {
let pdfium_relative_source_path = String::from("resources/libraries/");

View File

@ -72,6 +72,7 @@ pub fn start_runtime_api() {
crate::app_window::install_update,
crate::app_window::select_directory,
crate::app_window::select_file,
crate::app_window::save_file,
crate::secret::get_secret,
crate::secret::store_secret,
crate::secret::delete_secret,